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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
99
lib/widget/scanner/item_matcher.dart
Normal file
99
lib/widget/scanner/item_matcher.dart
Normal file
@ -0,0 +1,99 @@
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
|
||||
/// Ergebnis der Item-Auflösung beim Scan. Sealed, damit das UI per
|
||||
/// `switch` exhaustiv pro Fall eine passende Meldung zeigt.
|
||||
sealed class ItemMatch {
|
||||
const ItemMatch();
|
||||
const factory ItemMatch.ok(DeliveryItem item) = ItemMatchOk;
|
||||
const factory ItemMatch.notInDelivery() = ItemMatchNotInDelivery;
|
||||
const factory ItemMatch.notScannable() = ItemMatchNotScannable;
|
||||
const factory ItemMatch.allDone() = ItemMatchAllDone;
|
||||
const factory ItemMatch.allRemoved() = ItemMatchAllRemoved;
|
||||
const factory ItemMatch.notOpen() = ItemMatchNotOpen;
|
||||
}
|
||||
|
||||
class ItemMatchOk extends ItemMatch {
|
||||
const ItemMatchOk(this.item);
|
||||
final DeliveryItem item;
|
||||
}
|
||||
|
||||
class ItemMatchNotInDelivery extends ItemMatch {
|
||||
const ItemMatchNotInDelivery();
|
||||
}
|
||||
|
||||
class ItemMatchNotScannable extends ItemMatch {
|
||||
const ItemMatchNotScannable();
|
||||
}
|
||||
|
||||
class ItemMatchAllDone extends ItemMatch {
|
||||
const ItemMatchAllDone();
|
||||
}
|
||||
|
||||
class ItemMatchAllRemoved extends ItemMatch {
|
||||
const ItemMatchAllRemoved();
|
||||
}
|
||||
|
||||
class ItemMatchNotOpen extends ItemMatch {
|
||||
const ItemMatchNotOpen();
|
||||
}
|
||||
|
||||
/// Findet in der Lieferung das erste **nicht fertig** gescannte Item mit der
|
||||
/// gegebenen Article-Nummer. „Top-down"-Strategie: hat eine Lieferung zwei
|
||||
/// Belegzeilen mit demselben Artikel (z. B. 20 + 10), wird zuerst die
|
||||
/// niedrigere Belegzeile gefüllt. Erst wenn diese fertig ist, „rollt" der
|
||||
/// nächste Scan auf die zweite Zeile weiter.
|
||||
///
|
||||
/// [itemFilter] schränkt die betrachteten Positionen ein — z. B. nur
|
||||
/// Filial-Items in der Abhol-Phase. Ohne Filter werden alle Positionen
|
||||
/// berücksichtigt (Beladen-Phase).
|
||||
///
|
||||
/// Klassifiziert den Misserfolgs-Grund (nicht scanbar / bereits fertig /
|
||||
/// entfernt …), damit das UI dem Fahrer eine sinnvolle Meldung zeigt.
|
||||
ItemMatch matchItem({
|
||||
required Delivery delivery,
|
||||
required TourDetails details,
|
||||
required String articleNumber,
|
||||
bool Function(DeliveryItem item)? itemFilter,
|
||||
}) {
|
||||
final normalized = articleNumber.trim();
|
||||
final candidates = delivery.items
|
||||
.where((it) => itemFilter?.call(it) ?? true)
|
||||
.toList()
|
||||
..sort((a, b) => a.belegzeilenNr.compareTo(b.belegzeilenNr));
|
||||
final matchingArticle = candidates.where((it) {
|
||||
final art = details.articleOf(it.articleId);
|
||||
return art?.articleNumber == normalized;
|
||||
}).toList();
|
||||
|
||||
if (matchingArticle.isEmpty) {
|
||||
return const ItemMatch.notInDelivery();
|
||||
}
|
||||
|
||||
// Erstes Item, das wir tatsächlich scannen können.
|
||||
for (final item in matchingArticle) {
|
||||
if (item.isDone || item.isRemoved) continue;
|
||||
final article = details.articleOf(item.articleId);
|
||||
if (article == null || !article.scannable) continue;
|
||||
return ItemMatch.ok(item);
|
||||
}
|
||||
|
||||
// Kein scannbares offenes Item — Grund anhand der Item-Verteilung
|
||||
// klassifizieren, damit das UI eine sinnvolle Meldung zeigt.
|
||||
final allNotScannable = matchingArticle.every((it) {
|
||||
final art = details.articleOf(it.articleId);
|
||||
return art == null || !art.scannable;
|
||||
});
|
||||
if (allNotScannable) return const ItemMatch.notScannable();
|
||||
|
||||
final allRemoved = matchingArticle.every((it) => it.isRemoved);
|
||||
if (allRemoved) return const ItemMatch.allRemoved();
|
||||
|
||||
final allDone = matchingArticle.every((it) => it.isDone);
|
||||
if (allDone) return const ItemMatch.allDone();
|
||||
|
||||
// Gemischte Konstellation (z. B. eine Zeile entfernt, eine fertig) —
|
||||
// praktisch selten; konservativ als „nicht (mehr) offen" melden.
|
||||
return const ItemMatch.notOpen();
|
||||
}
|
||||
74
lib/widget/scanner/manual_entry_dialog.dart
Normal file
74
lib/widget/scanner/manual_entry_dialog.dart
Normal file
@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Öffnet den manuellen Scan-Code-Eingabe-Dialog und liefert den
|
||||
/// eingegebenen Code (oder `null` bei Abbruch / leerer Eingabe).
|
||||
Future<String?> showManualEntryDialog(BuildContext context) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => const ManualEntryDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fallback-Eingabe, wenn ein Sticker nicht scanbar ist (beschädigt,
|
||||
/// schlechtes Licht). Erwartet das gleiche Format wie der QR-Code:
|
||||
/// `Artikelnr;Kundennr;Belegnr`.
|
||||
class ManualEntryDialog extends StatefulWidget {
|
||||
const ManualEntryDialog({super.key});
|
||||
|
||||
@override
|
||||
State<ManualEntryDialog> createState() => _ManualEntryDialogState();
|
||||
}
|
||||
|
||||
class _ManualEntryDialogState extends State<ManualEntryDialog> {
|
||||
final _controller = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => _focusNode.requestFocus());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final v = _controller.text.trim();
|
||||
if (v.isEmpty) return;
|
||||
Navigator.of(context).pop(v);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Scan-Code eingeben'),
|
||||
content: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Artikelnr;Kundennr;Belegnr',
|
||||
hintText: 'z. B. BRETT-200;4711;AB-2026-0001',
|
||||
helperText: 'Format wie auf dem Sticker — drei Werte mit Semikolon',
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
child: const Text('Übernehmen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
28
lib/widget/scanner/scan_code_parser.dart
Normal file
28
lib/widget/scanner/scan_code_parser.dart
Normal file
@ -0,0 +1,28 @@
|
||||
/// Geparster Scan-Code im Format
|
||||
/// `<Artikelnummer>;<Kundennummer>;<Belegnummer>`.
|
||||
typedef ScanCode = ({String articleNumber, int customerErpId, String beleg});
|
||||
|
||||
/// Parst das QR-Code-Format `<Artikelnummer>;<Kundennummer>;<Belegnummer>`.
|
||||
///
|
||||
/// Trimmt jedes Feld, lehnt leere Felder und nicht-numerische
|
||||
/// Kundennummern ab. Liefert `null`, wenn das Format nicht stimmt — der
|
||||
/// Aufrufer übersetzt das in eine einheitliche „nicht vorgesehen"-Meldung,
|
||||
/// damit der Fahrer kein Backstage-Tech-Feedback bekommt.
|
||||
///
|
||||
/// Geteilt zwischen Beladen-Phase (`LoadingCustomerPage`) und Filial-Abholung
|
||||
/// (`FilialePickupScanPage`) — beide nutzen dasselbe Sticker-Format.
|
||||
ScanCode? parseScanCode(String raw) {
|
||||
final parts = raw.split(';');
|
||||
if (parts.length != 3) return null;
|
||||
final articleNumber = parts[0].trim();
|
||||
final customerStr = parts[1].trim();
|
||||
final beleg = parts[2].trim();
|
||||
if (articleNumber.isEmpty || beleg.isEmpty) return null;
|
||||
final customerErpId = int.tryParse(customerStr);
|
||||
if (customerErpId == null) return null;
|
||||
return (
|
||||
articleNumber: articleNumber,
|
||||
customerErpId: customerErpId,
|
||||
beleg: beleg,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user