Final commit.
This commit is contained in:
201
lib/widget/scanner/article_scanner_stripe.dart
Normal file
201
lib/widget/scanner/article_scanner_stripe.dart
Normal file
@ -0,0 +1,201 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
/// Schmaler Kamera-Stripe mit Torch- und Manual-Entry-Button sowie
|
||||
/// Zoom-Steuerung. Geteilt zwischen Beladen-Phase und Filial-Abholung.
|
||||
///
|
||||
/// Wichtig: sollte **einmal** und außerhalb eines `PageView` gemountet
|
||||
/// werden, damit der Kamera-Stream beim Wischen nicht abreißt (eine einzige
|
||||
/// Kamera-Instanz pro Page-Lebenszyklus).
|
||||
class ArticleScannerStripe extends StatefulWidget {
|
||||
const ArticleScannerStripe({
|
||||
super.key,
|
||||
required this.onBarcode,
|
||||
required this.onManualEntry,
|
||||
});
|
||||
|
||||
/// Roh-Wert eines erkannten Barcodes (bereits getrimmt, dedupliziert).
|
||||
final void Function(String code) onBarcode;
|
||||
|
||||
/// Tap auf das Tastatur-Icon → Aufrufer öffnet den Manual-Entry-Dialog.
|
||||
final VoidCallback onManualEntry;
|
||||
|
||||
@override
|
||||
State<ArticleScannerStripe> createState() => _ArticleScannerStripeState();
|
||||
}
|
||||
|
||||
class _ArticleScannerStripeState extends State<ArticleScannerStripe> {
|
||||
late final MobileScannerController _scanner = MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.normal,
|
||||
formats: const [
|
||||
BarcodeFormat.ean13,
|
||||
BarcodeFormat.ean8,
|
||||
BarcodeFormat.code128,
|
||||
BarcodeFormat.code39,
|
||||
BarcodeFormat.qrCode,
|
||||
],
|
||||
);
|
||||
|
||||
/// Dedupliziert Detections derselben Tastendruck-Geste: der Scanner liest
|
||||
/// dasselbe Code-Bild viele Frames lang. Nur wenn der Code für
|
||||
/// `_minRepeatGap` lang nicht gesehen wurde, lassen wir ihn erneut zu.
|
||||
String? _lastCode;
|
||||
DateTime _lastEmit = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
static const Duration _minRepeatGap = Duration(milliseconds: 1200);
|
||||
|
||||
/// Schrittweite der `-` / `+`-Buttons im normalisierten Zoom-Bereich
|
||||
/// [0.0, 1.0]. 5% pro Tastendruck — fühlt sich auf einem 6"-Display
|
||||
/// natürlich an, ohne dass der Fahrer zehnmal tappen muss.
|
||||
static const double _zoomStep = 0.05;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scanner.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
if (capture.barcodes.isEmpty) return;
|
||||
final raw = capture.barcodes.first.rawValue?.trim();
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
final now = DateTime.now();
|
||||
if (raw == _lastCode && now.difference(_lastEmit) < _minRepeatGap) {
|
||||
return;
|
||||
}
|
||||
_lastCode = raw;
|
||||
_lastEmit = now;
|
||||
widget.onBarcode(raw);
|
||||
}
|
||||
|
||||
Future<void> _setZoom(double normalized) async {
|
||||
final clamped = normalized.clamp(0.0, 1.0);
|
||||
await _scanner.setZoomScale(clamped);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 140,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: MobileScanner(
|
||||
controller: _scanner,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error) {
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Kamera nicht verfügbar: ${error.errorCode.name}',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: Material(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: IconButton(
|
||||
tooltip: 'Manuelle Eingabe',
|
||||
icon: const Icon(Icons.keyboard, color: Colors.white),
|
||||
onPressed: widget.onManualEntry,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 8,
|
||||
top: 8,
|
||||
child: Material(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: IconButton(
|
||||
tooltip: 'Taschenlampe',
|
||||
icon: const Icon(Icons.flash_on, color: Colors.white),
|
||||
onPressed: () => _scanner.toggleTorch(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_ZoomBar(controller: _scanner, onChange: _setZoom, step: _zoomStep),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Zoom-Steuerung: `-` Button links, Slider in der Mitte, `+` rechts.
|
||||
/// Bindet an `MobileScannerController.value.zoomScale` via
|
||||
/// ValueListenableBuilder — damit reagiert der Slider sofort, wenn das
|
||||
/// Plugin den Zoom selbst clampt (z. B. bei Geräten, die nicht den vollen
|
||||
/// Bereich unterstützen).
|
||||
class _ZoomBar extends StatelessWidget {
|
||||
const _ZoomBar({
|
||||
required this.controller,
|
||||
required this.onChange,
|
||||
required this.step,
|
||||
});
|
||||
|
||||
final MobileScannerController controller;
|
||||
final Future<void> Function(double normalized) onChange;
|
||||
final double step;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<MobileScannerState>(
|
||||
valueListenable: controller,
|
||||
builder: (context, state, _) {
|
||||
final zoom = state.zoomScale;
|
||||
return Material(
|
||||
color: Colors.black87,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Auszoomen',
|
||||
icon: const Icon(Icons.remove, color: Colors.white),
|
||||
onPressed: zoom <= 0.0 ? null : () => onChange(zoom - step),
|
||||
),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
activeTrackColor: Colors.white,
|
||||
inactiveTrackColor: Colors.white24,
|
||||
thumbColor: Colors.white,
|
||||
overlayColor: Colors.white24,
|
||||
trackHeight: 3,
|
||||
),
|
||||
child: Slider(
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
value: zoom.clamp(0.0, 1.0),
|
||||
onChanged: (v) => onChange(v),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Reinzoomen',
|
||||
icon: const Icon(Icons.add, color: Colors.white),
|
||||
onPressed: zoom >= 1.0 ? null : () => onChange(zoom + step),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user