Final commit.

This commit is contained in:
Dennis Nemec
2026-06-01 17:12:28 +02:00
parent 3ecbc82885
commit a9bf8ecdd1
385 changed files with 29081 additions and 12089 deletions

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

View 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();
}

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

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