Files
Holzleitner-Lieferservice-App/lib/widget/scanner/article_scanner_stripe.dart
Dennis Nemec a9bf8ecdd1 Final commit.
2026-06-01 17:12:28 +02:00

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),
),
],
),
),
);
},
);
}
}