202 lines
6.7 KiB
Dart
202 lines
6.7 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|