Files
Holzleitner-Lieferservice-App/lib/feature/loading/presentation/loading_customer_page.dart
Dennis Nemec 446cf73347 feat(loading): Abschluss-Dialog 'Alles gescannt - Auslieferung starten?' im Scanner
Sobald im Beladen-Scanner der letzte Pflicht-Scan erledigt ist (alle eigenen,
aktiven Lieferungen im Standardlager fertig - gleiche Bedingung wie der
'Auslieferungs-Phase starten'-Gate der Uebersicht), erscheint einmalig ein
Dialog: bestaetigt 'alles gescannt' und fragt, ob die Auslieferung starten soll.
'Auslieferung starten' -> PhaseSet(ausliefern) + Scanner schliessen; 'Spaeter'
-> nur schliessen. BlocConsumer<TourBloc>-Listener + Einmal-Flag (Reset wenn
wieder etwas offen).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:07:22 +02:00

1423 lines
53 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/article.dart';
import 'package:hl_lieferservice/domain/entity/customer.dart';
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';
import 'package:hl_lieferservice/domain/entity/warehouse.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
import 'package:hl_lieferservice/feature/loading/widget/reason_catalog.dart';
import 'package:hl_lieferservice/feature/loading/widget/reason_picker_sheet.dart';
import 'package:hl_lieferservice/widget/scanner/article_scanner_stripe.dart';
import 'package:hl_lieferservice/widget/scanner/item_matcher.dart';
import 'package:hl_lieferservice/widget/scanner/manual_entry_dialog.dart';
import 'package:hl_lieferservice/widget/scanner/scan_code_parser.dart';
/// Vollbild-Sicht eines Kunden in der Beladen-Phase mit aktivem Scanner.
///
/// Aufbau:
/// 1. **Scanner-Stripe** oben — *außerhalb* des PageView. Damit gibt es
/// nur eine einzige Kamera-Instanz im Page-Lebenszyklus; das Wischen
/// zwischen Kunden lässt den Stream nicht abreißen. (Würde der Stripe
/// pro PageView-Page neu instanziiert, käme er sich mit den von
/// PageView vorgeladenen Nachbarseiten ins Gehege und der Viewport
/// würde weiß.)
/// 2. **Zoom-Bar** direkt unter dem Viewport — `-` / Slider / `+` für
/// Daumenbedienung. Gebunden an `MobileScannerController.setZoomScale`.
/// 3. **PageView** mit Kundenkopf + Item-Liste pro Lieferung.
///
/// Scan-Pipeline:
/// - Barcode = Artikelnummer.
/// - UI sucht in der **aktuell sichtbaren** Lieferung das erste nicht
/// fertig gescannte Item mit dieser Article-Nr und feuert `ScanItem`.
/// - Bloc inkrementiert lokal sofort, ruft das Backend, rollt bei
/// `rejected` zurück.
class LoadingCustomerPage extends StatefulWidget {
const LoadingCustomerPage({super.key, this.initialIndex = 0});
final int initialIndex;
@override
State<StatefulWidget> createState() => _LoadingCustomerPageState();
}
class _LoadingCustomerPageState extends State<LoadingCustomerPage> {
late final PageController _pageController =
PageController(initialPage: widget.initialIndex);
/// Index der gerade sichtbaren Lieferung. Wird vom PageView gesetzt und
/// vom (lebenslang einen) Scanner für die Barcode-Auflösung benutzt.
int _currentIndex = 0;
/// Verhindert, dass der „Alles gescannt"-Abschluss-Dialog mehrfach erscheint.
/// Wird zurückgesetzt, sobald wieder etwas zu beladen offen ist.
bool _completionPromptShown = false;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
bool _multiCarTeam(BuildContext context) {
final state = context.read<CarsBloc>().state;
return state is CarsLoaded && state.cars.length >= 2;
}
List<Delivery> _ownInLoadingOrder(
BuildContext context,
TourDetails details,
String carId,
) {
final relevant = _multiCarTeam(context)
? details.deliveriesSorted
.where((d) => d.assignedCarId == carId)
.toList()
: details.deliveriesSorted;
return relevant.reversed.toList();
}
/// Verarbeitet einen Scan-Code (Kamera **oder** Manual-Entry).
///
/// Validierung: das geparste Tripel **Artikelnummer × Kundennummer ×
/// Belegnummer** muss *exakt* zur aktuell sichtbaren Lieferung passen.
/// Schon ein Mismatch in einer der drei Dimensionen → Snackbar
/// „nicht vorgesehen". Das schützt den Fahrer davor, versehentlich
/// einen QR-Code für eine **andere** Lieferung anzuwenden — die alte
/// Logik („alles, was die Artikelnummer kennt, wird gescannt") wäre
/// gegen Verwechslungen wehrlos.
///
/// `customer.erpCustomerId` = `Kunden.Kundennummer` aus dem ERP-Sync
/// (entspricht der Kundennummer im QR-Mittelfeld).
void _onBarcode({
required String code,
required List<Delivery> deliveries,
required TourDetails details,
required String carId,
}) {
if (deliveries.isEmpty) return;
final safeIndex = _currentIndex.clamp(0, deliveries.length - 1);
final delivery = deliveries[safeIndex];
final customer = details.customerOf(delivery);
final parsed = parseScanCode(code);
// Format-Fehler oder Konstellation passt nicht zur aktuell sichtbaren
// Lieferung → eindeutig „nicht vorgesehen".
if (parsed == null ||
customer?.erpCustomerId != parsed.customerErpId ||
delivery.erpBelegnummer != parsed.beleg) {
_showScanSnackbar(_notIntendedMessage);
return;
}
final match = matchItem(
delivery: delivery,
details: details,
articleNumber: parsed.articleNumber,
);
switch (match) {
case ItemMatchOk(:final item):
context
.read<TourBloc>()
.add(ScanItem(deliveryItemId: item.id, actorCarId: carId));
case ItemMatchNotInDelivery():
_showScanSnackbar(_notIntendedMessage);
case ItemMatchNotScannable():
_showScanSnackbar(
'Diese Position ist nicht zum Scannen vorgesehen '
'(Dienstleistung / Pauschale).',
);
case ItemMatchAllDone():
_showScanSnackbar('Diese Position ist bereits vollständig gescannt.');
case ItemMatchAllRemoved():
_showScanSnackbar('Diese Position wurde aus der Lieferung entfernt.');
case ItemMatchNotOpen():
_showScanSnackbar('Diese Position ist nicht (mehr) offen.');
}
}
static const String _notIntendedMessage =
'Dieser Artikel ist für diese Lieferung nicht vorgesehen';
void _showScanSnackbar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 2),
),
);
}
Future<void> _openManualEntry({
required List<Delivery> deliveries,
required TourDetails details,
required String carId,
}) async {
final code = await showManualEntryDialog(context);
if (code == null || code.isEmpty) return;
if (!mounted) return;
_onBarcode(
code: code,
deliveries: deliveries,
details: details,
carId: carId,
);
}
// ─── Delivery-Lifecycle-Aktionen ──────────────────────────────────────
Future<void> _onHoldDelivery(Delivery delivery) async {
final result = await showReasonPickerSheet(
context: context,
title: 'Lieferung pausieren',
presets: ReasonCatalog.deliveryHold,
confirmLabel: 'Pausieren',
);
if (result == null || !mounted) return;
context
.read<TourBloc>()
.add(HoldDelivery(deliveryId: delivery.id, reason: result.reason));
}
Future<void> _onCancelDelivery(Delivery delivery) async {
// Cancel ist endgültig — Bestätigungsschritt vor dem Reason-Picker.
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Lieferung abbrechen?'),
content: const Text(
'Eine abgebrochene Lieferung kann nicht wieder aktiviert werden.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Zurück'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
foregroundColor: Theme.of(ctx).colorScheme.onError,
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Weiter'),
),
],
),
);
if (confirmed != true || !mounted) return;
final result = await showReasonPickerSheet(
context: context,
title: 'Grund für Abbruch',
presets: ReasonCatalog.deliveryCancel,
confirmLabel: 'Lieferung abbrechen',
);
if (result == null || !mounted) return;
context
.read<TourBloc>()
.add(CancelDelivery(deliveryId: delivery.id, reason: result.reason));
}
Future<void> _onResumeDelivery(Delivery delivery) async {
// Bei einer abgebrochenen Lieferung ist die Wiederherstellung
// semantisch riskanter als ein normales Hold-Resume — der Cancel
// wurde vom Fahrer aktiv bestätigt. Wir holen deshalb eine zweite
// Zustimmung ein, bevor wir das Resume feuern. Beim Hold-Resume
// sparen wir den Schritt: alltäglich und reversibel.
if (delivery.state == DeliveryState.canceled) {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Lieferung wiederherstellen?'),
content: Text(
'Die Lieferung wurde abgebrochen'
'${delivery.stateReason != null ? ' (Grund: ${delivery.stateReason})' : ''}.'
'\n\nWiederhergestellte Lieferungen erscheinen wieder in der '
'Beladen-Phase. Die ursprüngliche Cancel-Begründung wird dabei '
'gelöscht.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Wiederherstellen'),
),
],
),
);
if (confirmed != true || !mounted) return;
}
context.read<TourBloc>().add(ResumeDelivery(deliveryId: delivery.id));
}
// ─── Item-Aktionen ────────────────────────────────────────────────────
/// Wird vom `PopupMenuButton` der jeweiligen Item-Row angeruft. Welche
/// Optionen verfügbar sind, entscheidet die Row selbst über den
/// aktuellen `ScanStatus` — hier landet nur die schon ausgewählte
/// Aktion zur Verarbeitung.
Future<void> _onItemAction({
required DeliveryItem item,
required _ItemAction action,
required String carId,
}) async {
switch (action) {
case _ItemAction.remove:
// Restmenge, die noch gutgeschrieben/entfernt werden kann.
final remaining =
item.requiredQuantity - item.scanProgress.creditedQuantity;
final result = await showReasonPickerSheet(
context: context,
title: 'Grund für das Entfernen',
presets: ReasonCatalog.itemRemove,
confirmLabel: 'Entfernen',
maxQuantity: remaining,
);
if (result == null || !mounted) return;
context.read<TourBloc>().add(RemoveItem(
deliveryItemId: item.id,
actorCarId: carId,
reason: result.reason,
quantity: result.quantity,
));
case _ItemAction.unremove:
context.read<TourBloc>().add(UnremoveItem(
deliveryItemId: item.id,
actorCarId: carId,
));
case _ItemAction.manualConfirm:
// Fallback ohne Barcode: die ganze Restmenge manuell als geladen
// bestätigen. Bewusste Aussage → kurzer Bestätigungs-Dialog; das
// Backend protokolliert den Scan als `manual`.
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Manuell bestätigen'),
content: const Text(
'Diese Position ohne Scan als vollständig geladen markieren? '
'Das wird als manuelle Bestätigung protokolliert.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Als geladen bestätigen'),
),
],
),
);
if (confirmed != true || !mounted) return;
context.read<TourBloc>().add(ScanItem(
deliveryItemId: item.id,
actorCarId: carId,
manual: true,
));
}
}
/// Reagiert auf jedes TourBloc-Update: Sind ALLE eigenen, **aktiven**
/// Lieferungen im Standardlager fertig beladen (dieselbe Bedingung wie der
/// „Auslieferungs-Phase starten"-Gate der Beladen-Übersicht)? Genau beim
/// Übergang „noch offen → alles fertig" (also nach dem letzten Pflicht-Scan)
/// erscheint einmalig der Abschluss-Dialog.
void _maybePromptLoadingComplete(
BuildContext context,
TourState state,
String carId,
) {
if (state is! TourLoaded) return;
final deliveries = _ownInLoadingOrder(context, state.details, carId);
final active =
deliveries.where((d) => d.state == DeliveryState.active).toList();
final allDone = active.isNotEmpty &&
active.every(state.details.standardWarehouseLoadingDone);
if (!allDone) {
// Wieder etwas offen (Item entfernt/zurückgesetzt) → Dialog darf erneut.
_completionPromptShown = false;
return;
}
if (_completionPromptShown) return;
_completionPromptShown = true;
_showLoadingCompleteDialog(carId);
}
/// Abschluss-Dialog der Beladung: weist darauf hin, dass alles gescannt ist,
/// und fragt, ob die Auslieferung gestartet werden soll. Bei „Ja" wird die
/// Phase auf `ausliefern` gesetzt (PhaseBloc, app-weit) und der Scanner
/// geschlossen — der Phasen-Router (`home`) zeigt dann die Auslieferungs-
/// Übersicht. „Später" schließt nur den Dialog (Start wie bisher über die
/// Übersicht möglich).
Future<void> _showLoadingCompleteDialog(String carId) async {
final start = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
icon: const Icon(Icons.check_circle, color: Colors.green, size: 40),
title: const Text('Alles gescannt'),
content: const Text(
'Alle zu beladenden Artikel sind gescannt. '
'Möchten Sie die Auslieferung starten?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Später'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Auslieferung starten'),
),
],
),
);
if (start != true || !mounted) return;
context
.read<PhaseBloc>()
.add(PhaseSet(carId: carId, phase: DeliveryPhase.ausliefern));
// Scanner schließen → zurück zum Phasen-Router, der nun 'ausliefern' zeigt.
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CarSelectBloc, CarSelectState>(
builder: (context, carState) {
final carId =
carState is CarSelectComplete ? carState.selectedCar.id : '';
return BlocConsumer<TourBloc, TourState>(
listener: (context, tourState) =>
_maybePromptLoadingComplete(context, tourState, carId),
builder: (context, tourState) {
if (tourState is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final deliveries =
_ownInLoadingOrder(context, tourState.details, carId);
if (deliveries.isEmpty) {
return Scaffold(
appBar: AppBar(title: const Text('Beladung')),
body: const Center(
child: Text('Keine Lieferungen zugewiesen.'),
),
);
}
return Scaffold(
// AppBar bewusst schlicht — die Lieferungs-Lifecycle-Aktionen
// (Pausieren / Abbrechen / Wiederherstellen) sind in den
// Kunden-Header gewandert, vertikal mittig rechts. So
// hat der Fahrer den Bezug „Aktion betrifft DIESEN Kunden"
// direkt vor Augen.
//
// Farbe primary statt M3-Default `surface`: die Loading-
// Customer-Page ist eine Sub-Page der LoadingOverviewPage,
// deren `PhaseStepper`-AppBar primary ist. Eine weiße
// AppBar hier bricht den Flow visuell — primary stellt die
// Konsistenz wieder her.
appBar: AppBar(
title: const Text('Beladung'),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
body: Column(
children: [
ArticleScannerStripe(
onBarcode: (code) => _onBarcode(
code: code,
deliveries: deliveries,
details: tourState.details,
carId: carId,
),
onManualEntry: () => _openManualEntry(
deliveries: deliveries,
details: tourState.details,
carId: carId,
),
),
Expanded(
child: PageView.builder(
controller: _pageController,
onPageChanged: (i) =>
setState(() => _currentIndex = i),
itemCount: deliveries.length,
itemBuilder: (context, index) {
final delivery = deliveries[index];
return _CustomerBody(
delivery: delivery,
details: tourState.details,
position: index + 1,
totalCount: deliveries.length,
onItemAction: (item, action) => _onItemAction(
item: item,
action: action,
carId: carId,
),
onHoldDelivery: () => _onHoldDelivery(delivery),
onCancelDelivery: () => _onCancelDelivery(delivery),
onResumeDelivery: () => _onResumeDelivery(delivery),
);
},
),
),
],
),
);
},
);
},
);
}
}
class _CustomerBody extends StatelessWidget {
const _CustomerBody({
required this.delivery,
required this.details,
required this.position,
required this.totalCount,
required this.onItemAction,
required this.onHoldDelivery,
required this.onCancelDelivery,
required this.onResumeDelivery,
});
final Delivery delivery;
final TourDetails details;
/// 1-basierte Position dieser Lieferung in der Belade-Reihenfolge.
final int position;
/// Gesamtanzahl der Lieferungen — für „X von Y".
final int totalCount;
final void Function(DeliveryItem item, _ItemAction action) onItemAction;
final VoidCallback onHoldDelivery;
final VoidCallback onCancelDelivery;
final VoidCallback onResumeDelivery;
@override
Widget build(BuildContext context) {
final customer = details.customerOf(delivery);
// Items werden vom Aggregat-Helper schon nach Lager gruppiert
// geliefert: Standardlager zuerst, danach Filiale alphabetisch.
// Nicht-scanbare Positionen und `removed`-Items sind dabei schon
// ausgefiltert.
final groups = details.itemsGroupedByWarehouse(delivery);
// Nicht-scanbare Positionen (Dienstleistung / Pauschale / Fracht) — die
// werden NICHT beladen/gescannt, sollen aber sichtbar sein, damit der
// Fahrer auch eine reine Dienstleistungs-Lieferung als „echte Anfahrt"
// erkennt. Liegen außerhalb von `groups` (die nur scanbare Items führen).
final serviceItems = details.nonScannableItems(delivery).toList();
return Column(
children: [
// Header bekommt einen eigenen, leicht abgehobenen Hintergrund
// aus dem Material-3-Surface-Stack (`surfaceContainerHigh`).
// Das hebt den Kunden-/Lieferungs-Block visuell vom Item-Body
// ab — Dark-Mode-tauglich, ohne dass eine Hardcodierte Farbe
// bei einem späteren Theme-Wechsel verkehrt aussieht.
Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
padding: const EdgeInsets.fromLTRB(16, 16, 4, 16),
// Vertikal zentriert: Avatar links, Kunden-/Lieferungs-Info in
// der Mitte, Aktions-Menü ganz rechts (statt wie früher im
// AppBar oben). Der Bezug „diese Aktion betrifft genau DIESEN
// Kunden" wird dadurch räumlich klar.
child: Row(
children: [
_CustomerAvatar(customer: customer),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Lieferung $position von $totalCount',
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
),
),
const SizedBox(height: 2),
Text(
customer?.name ?? '⟨Unbekannter Kunde⟩',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 4),
Text(
delivery.deliveryAddressSnapshot.oneLine,
style: Theme.of(context).textTheme.bodyMedium,
),
if (delivery.state != DeliveryState.active) ...[
const SizedBox(height: 8),
_DeliveryStateBadge(delivery: delivery),
],
],
),
),
_DeliveryActionsMenu(
delivery: delivery,
onHold: onHoldDelivery,
onCancel: onCancelDelivery,
onResume: onResumeDelivery,
),
],
),
),
// Kein expliziter Divider mehr — der farbige Header-Block trennt
// sich schon visuell vom Item-Body.
Expanded(
// Eine einzige Scroll-Liste: zuerst die scanbaren Items je Lager
// (Standardlager, dann Filiale), darunter — sofern vorhanden — die
// gebuchten Dienstleistungen (nicht-scanbare Positionen). Die
// Dienstleistungen werden GENAU SO wie Standardlager-Artikel
// dargestellt, nur mit dem Hinweis „kein Scanvorgang notwendig". So
// wirkt auch eine reine Dienstleistungs-Lieferung nicht leer.
//
// Wenig Items pro Lieferung → `ListView` mit children-Liste reicht
// performant aus und ist lesbarer als ein Index-Builder.
//
// Bottom-Inset: kein Bottom-Bar in dieser Page → die System-
// Navigationsleiste selbst freihalten.
child: ListView(
padding: EdgeInsets.fromLTRB(
0,
4,
0,
4 + MediaQuery.viewPaddingOf(context).bottom,
),
children: [
// Nur wenn es WEDER scanbare Ware NOCH eine Dienstleistung gibt,
// ist wirklich nichts zu tun.
if (groups.isEmpty && serviceItems.isEmpty)
const _NothingToLoadHint(),
for (final group in groups) ...[
_WarehouseSectionHeader(
warehouse: group.warehouse,
items: group.items,
),
for (final item in _parentFirst(group.items))
_ItemRow(
item: item,
details: details,
onAction: (action) => onItemAction(item, action),
),
],
// Gebuchte Dienstleistungen (nicht-scanbare Positionen): eigene
// Zwischenüberschrift „Dienstleistungen" (optisch wie
// Standardlager) und dieselbe Item-Card wie scanbare Artikel —
// einziger Unterschied ist der Hinweis, dass kein Scanvorgang
// nötig ist (`scanNotRequired`).
if (serviceItems.isNotEmpty) ...[
const _ServiceSectionHeader(),
for (final item in _parentFirst(serviceItems))
_ItemRow(
item: item,
details: details,
onAction: (action) => onItemAction(item, action),
scanNotRequired: true,
),
],
],
),
),
],
);
}
}
/// Sektions-Kopf vor den gebuchten Dienstleistungen (nicht-scanbare
/// Positionen) in der Beladen-Ansicht. Bewusst optisch identisch zum
/// `_WarehouseSectionHeader` („Standardlager"): gleiche Maße, gleiche
/// neutrale Farbe — die Dienstleistungen reihen sich damit nahtlos in die
/// Lager-Gliederung ein.
///
/// Anders als der Lager-Kopf trägt dieser KEINEN „fertig/gesamt"-Zähler
/// rechts: Dienstleistungen werden nicht gescannt, ein Fortschrittszähler
/// wäre irreführend. Stattdessen sagt der Subtitle direkt, dass hier kein
/// Scanvorgang nötig ist.
class _ServiceSectionHeader extends StatelessWidget {
const _ServiceSectionHeader();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = theme.colorScheme.onSurfaceVariant;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.handyman_outlined, size: 18, color: color),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Dienstleistungen',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: color,
letterSpacing: 0.4,
),
),
Text(
'Kein Scanvorgang notwendig',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
);
}
}
/// Vollbreiter Hinweis am unteren Rand einer Dienstleistungs-Card: diese
/// Position wird NICHT gescannt/beladen. Sitzt an genau der Stelle, an der
/// bei scanbaren Artikeln der „Manuell als geladen bestätigen"-Button steht
/// (der bei nicht-scanbaren Positionen entfällt) — so bleibt die Card-Geometrie
/// identisch zu den Standardlager-Artikeln, nur die Aussage ist eine andere.
class _ScanNotRequiredHint extends StatelessWidget {
const _ScanNotRequiredHint();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = theme.colorScheme.onSurfaceVariant;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.info_outline, size: 16, color: color),
const SizedBox(width: 6),
Flexible(
child: Text(
'Kein Scanvorgang notwendig',
style: TextStyle(
fontSize: 13,
color: color,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
/// Sektions-Kopf vor den Items eines Lagers. Visuell klar getrennt
/// (Standardlager vs. Filiale): Standardlager ist der primäre
/// Arbeitsplatz und damit neutral koloriert; Filial-Sections
/// bekommen den Orange-Akzent und einen Hinweis-Text, dass sie nicht
/// am aktuellen Standort geladen werden.
///
/// Counter rechts zeigt „fertige Items / Items in dieser Sektion".
class _WarehouseSectionHeader extends StatelessWidget {
const _WarehouseSectionHeader({
required this.warehouse,
required this.items,
});
final Warehouse warehouse;
final List<DeliveryItem> items;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isStandard = warehouse.isStandard;
// Counter zählt nur **aktive** (nicht entfernte) Positionen — sonst
// wäre eine Lieferung mit allen Items entfernt nie „fertig" (0 / N),
// obwohl es nichts mehr zu beladen gibt. Entfernte Zeilen bleiben in
// der Liste sichtbar (durchgestrichen), zählen aber nicht.
final activeItems = items.where((it) => !it.isRemoved).toList();
final doneCount = activeItems.where((it) => it.isDone).length;
final color = isStandard
? theme.colorScheme.onSurfaceVariant
: Colors.amber.shade800;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isStandard ? Icons.inventory_outlined : Icons.warehouse_outlined,
size: 18,
color: color,
),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isStandard
? 'Standardlager'
: 'Filiale: ${warehouse.name}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: color,
letterSpacing: 0.4,
),
),
Text(
isStandard
? 'Hier wird jetzt beladen'
: 'Wird in der Filiale separat geholt',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
Padding(
padding: const EdgeInsets.only(top: 1),
child: Text(
'$doneCount / ${activeItems.length}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
),
],
),
);
}
}
/// Wird angezeigt, wenn eine Lieferung in der Beladen-Phase WEDER scanbare
/// Ware NOCH eine gebuchte Dienstleistung hat — also wirklich nichts zu tun
/// ist. Macht dem Fahrer klar, dass das ein normaler Zustand ist und kein
/// Datenfehler. (Gibt es eine Dienstleistung, erscheint stattdessen der
/// Abschnitt „Dienstleistungen" mit der echten Position.)
class _NothingToLoadHint extends StatelessWidget {
const _NothingToLoadHint();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(32, 32, 32, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inbox_outlined, size: 56, color: theme.colorScheme.outline),
const SizedBox(height: 12),
Text(
'Für diesen Kunden ist nichts zu beladen.',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Text(
'Die Lieferung enthält keine zu ladende Ware.',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
);
}
}
/// Runder Initialen-Avatar links neben dem Kundennamen. Macht den
/// sonst sehr text-lastigen Header griffiger und liefert dem Fahrer
/// einen visuellen Anker: gleicher Kunde = gleiche Farbe.
///
/// Farbe ist deterministisch aus der `customer.id` abgeleitet, damit
/// Re-Builds und Page-Wechsel den Avatar nicht „flackern" lassen.
/// Initialen kommen aus dem Namen — Vor- und Zunamen kombiniert,
/// einzelne Worte mit einem Buchstaben.
class _CustomerAvatar extends StatelessWidget {
const _CustomerAvatar({required this.customer});
final Customer? customer;
/// Kleine kuratierte Palette — kräftig genug zum Erkennen, aber nicht
/// schreiend. Reihenfolge ist Absicht: die ersten Farben fallen am
/// stärksten auf und sind damit für die häufigsten Kunden „zuerst
/// dran" (Hash-Modulo verteilt — über die Zeit ausgeglichen).
static const List<Color> _palette = [
Color(0xFF1976D2), // blue 700
Color(0xFF388E3C), // green 700
Color(0xFFEF6C00), // orange 800
Color(0xFF7B1FA2), // purple 700
Color(0xFF00838F), // cyan 800
Color(0xFF5D4037), // brown 700
Color(0xFF455A64), // blueGrey 700
];
String get _initials {
final name = customer?.name.trim() ?? '';
if (name.isEmpty) return '?';
final parts = name
.split(RegExp(r'\s+'))
.where((p) => p.isNotEmpty)
.toList(growable: false);
if (parts.isEmpty) return '?';
if (parts.length == 1) {
return parts.first.characters.first.toUpperCase();
}
final first = parts.first.characters.first;
final last = parts.last.characters.first;
return '$first$last'.toUpperCase();
}
Color get _backgroundColor {
final seed = customer?.id.hashCode ?? 0;
return _palette[seed.abs() % _palette.length];
}
@override
Widget build(BuildContext context) {
if (customer == null) {
// Fallback: graues Person-Icon, weil kein Name → keine Initialen.
return const CircleAvatar(
radius: 26,
backgroundColor: Colors.grey,
child: Icon(Icons.person_outline, color: Colors.white),
);
}
return CircleAvatar(
radius: 26,
backgroundColor: _backgroundColor,
child: Text(
_initials,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
);
}
}
/// Farbiger Status-Badge unterhalb der Adresse — sichtbar bei `held`,
/// `canceled`, `completed`. Bei `active` blendet der Aufrufer den Badge
/// ganz aus, damit der Default-Fall die UI nicht zumüllt.
class _DeliveryStateBadge extends StatelessWidget {
const _DeliveryStateBadge({required this.delivery});
final Delivery delivery;
@override
Widget build(BuildContext context) {
final (color, label, icon) = switch (delivery.state) {
DeliveryState.active => (Colors.blue, 'Aktiv', Icons.local_shipping),
DeliveryState.held => (Colors.orange, 'Pausiert', Icons.pause_circle),
DeliveryState.canceled => (Colors.red, 'Abgebrochen', Icons.cancel),
DeliveryState.completed =>
(Colors.green, 'Abgeschlossen', Icons.check_circle),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
if (delivery.stateReason != null &&
delivery.stateReason!.isNotEmpty) ...[
const SizedBox(width: 8),
Flexible(
child: Text(
'· ${delivery.stateReason}',
overflow: TextOverflow.ellipsis,
),
),
],
],
),
);
}
}
/// AppBar-3-Punkte-Menü mit Delivery-Lifecycle-Aktionen. Optionen sind
/// kontextabhängig vom aktuellen `state` — endgültige Zustände
/// (canceled, completed) bieten keine Aktion mehr; das Icon verschwindet
/// dann ganz, damit der Fahrer keine Sackgasse antippt.
class _DeliveryActionsMenu extends StatelessWidget {
const _DeliveryActionsMenu({
required this.delivery,
required this.onHold,
required this.onCancel,
required this.onResume,
});
final Delivery delivery;
final VoidCallback onHold;
final VoidCallback onCancel;
final VoidCallback onResume;
@override
Widget build(BuildContext context) {
final items = <PopupMenuEntry<_DeliveryAction>>[];
switch (delivery.state) {
case DeliveryState.active:
items.add(const PopupMenuItem(
value: _DeliveryAction.hold,
child: ListTile(
leading: Icon(Icons.pause_circle_outline),
title: Text('Lieferung pausieren'),
contentPadding: EdgeInsets.zero,
),
));
items.add(const PopupMenuItem(
value: _DeliveryAction.cancel,
child: ListTile(
leading: Icon(Icons.cancel_outlined, color: Colors.red),
title: Text('Lieferung abbrechen'),
contentPadding: EdgeInsets.zero,
),
));
case DeliveryState.held:
items.add(const PopupMenuItem(
value: _DeliveryAction.resume,
child: ListTile(
leading: Icon(Icons.play_circle_outline),
title: Text('Lieferung fortsetzen'),
contentPadding: EdgeInsets.zero,
),
));
items.add(const PopupMenuItem(
value: _DeliveryAction.cancel,
child: ListTile(
leading: Icon(Icons.cancel_outlined, color: Colors.red),
title: Text('Lieferung abbrechen'),
contentPadding: EdgeInsets.zero,
),
));
case DeliveryState.canceled:
items.add(const PopupMenuItem(
value: _DeliveryAction.resume,
child: ListTile(
leading: Icon(Icons.restore, color: Colors.green),
title: Text('Lieferung wiederherstellen'),
contentPadding: EdgeInsets.zero,
),
));
case DeliveryState.completed:
return const SizedBox.shrink();
}
return PopupMenuButton<_DeliveryAction>(
tooltip: 'Lieferungs-Aktionen',
onSelected: (action) {
switch (action) {
case _DeliveryAction.hold:
onHold();
case _DeliveryAction.cancel:
onCancel();
case _DeliveryAction.resume:
onResume();
}
},
itemBuilder: (_) => items,
);
}
}
enum _DeliveryAction { hold, cancel, resume }
enum _ItemAction { remove, unremove, manualConfirm }
/// Karten-Darstellung einer Item-Position in der Beladen-Phase.
///
/// Visueller Stil orientiert sich an `_OverviewTile` (Lieferungs-Karten
/// in der Beladen-Übersicht): semi-transparente Hintergrundfarbe + Border
/// codieren den Status, links ein Status-Icon, rechts die Mengen-Anzeige.
/// Bei `isDone` ein zusätzliches grünes Häkchen, damit „fertig" ohne
/// Mengenrechnung auf einen Blick erkennbar ist.
/// Sortiert Items so, dass innerhalb einer Belegzeile der Oberartikel VOR
/// seinen Komponenten steht (für die eingerückte Darstellung).
List<DeliveryItem> _parentFirst(List<DeliveryItem> items) {
final sorted = List<DeliveryItem>.of(items);
sorted.sort((a, b) {
final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr);
if (byLine != 0) return byLine;
final byParent = (a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0);
if (byParent != 0) return byParent;
return (a.komponentenArtikelNr ?? '').compareTo(b.komponentenArtikelNr ?? '');
});
return sorted;
}
class _ItemRow extends StatelessWidget {
const _ItemRow({
required this.item,
required this.details,
required this.onAction,
this.scanNotRequired = false,
});
final DeliveryItem item;
final TourDetails details;
final void Function(_ItemAction action) onAction;
/// `true` für gebuchte Dienstleistungen (nicht-scanbare Positionen): die
/// Card wird GENAU SO wie ein Standardlager-Artikel gerendert, bekommt aber
/// unten den Hinweis „Kein Scanvorgang notwendig". Der Manuell-Button
/// entfällt bei nicht-scanbaren Positionen ohnehin (`canManualConfirm`).
final bool scanNotRequired;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final article = details.articleOf(item.articleId);
final warehouse = details.warehouseOf(item.warehouseId);
final isExternalWarehouse = warehouse != null && !warehouse.isStandard;
// Manueller Fallback-Button: nur für scanbare, noch offene Positionen
// (nicht done/entfernt/pausiert) — analog dazu, was ein Barcode-Scan
// dieser Zeile tun würde.
final canManualConfirm = article != null &&
article.scannable &&
!item.isDone &&
!item.isRemoved &&
!item.isHeld;
// Status-abhängiger Style — gleiches Farbschema wie `_OverviewTile`,
// damit Übersicht und Detail visuell zusammenpassen.
final Color cardColor;
final Color borderColor;
final Color titleColor;
final Color quantityColor;
final IconData leadingIcon;
final Color leadingIconColor;
final String? statusBadgeLabel;
if (item.isRemoved) {
cardColor = Colors.grey.withValues(alpha: 0.08);
borderColor = Colors.grey.withValues(alpha: 0.35);
titleColor = Colors.grey.shade700;
quantityColor = Colors.grey;
leadingIcon = Icons.delete_outline;
leadingIconColor = Colors.grey.shade700;
statusBadgeLabel = 'Entfernt';
} else if (item.isHeld) {
cardColor = Colors.orange.withValues(alpha: 0.07);
borderColor = Colors.orange.withValues(alpha: 0.35);
titleColor = Colors.orange.shade800;
quantityColor = Colors.orange.shade800;
leadingIcon = Icons.pause_circle_outline;
leadingIconColor = Colors.orange.shade800;
statusBadgeLabel = 'Pausiert';
} else if (item.isDone) {
cardColor = Colors.green.withValues(alpha: 0.07);
borderColor = Colors.green.withValues(alpha: 0.35);
titleColor = Colors.green.shade700;
quantityColor = Colors.green.shade700;
leadingIcon = Icons.inventory_2_outlined;
leadingIconColor = Colors.green.shade700;
statusBadgeLabel = null;
} else if (item.scanProgress.scannedQuantity > 0) {
cardColor = Colors.orange.withValues(alpha: 0.07);
borderColor = Colors.orange.withValues(alpha: 0.35);
titleColor = Colors.orange.shade800;
quantityColor = Colors.orange.shade800;
leadingIcon = Icons.inventory_2_outlined;
leadingIconColor = Colors.orange.shade800;
statusBadgeLabel = null;
} else {
cardColor = theme.colorScheme.surfaceContainerLow;
borderColor = Colors.transparent;
titleColor = theme.colorScheme.onSurface;
quantityColor = theme.colorScheme.onSurface;
leadingIcon = Icons.inventory_2_outlined;
leadingIconColor = theme.colorScheme.onSurfaceVariant;
statusBadgeLabel = null;
}
return Opacity(
opacity: item.isRemoved ? 0.55 : 1.0,
child: Card(
// Komponenten weiter links eingerückt → gehören zum Oberartikel darüber.
margin: EdgeInsets.only(
left: item.isComponent ? 32 : 12,
right: 12,
top: 4,
bottom: 4,
),
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: borderColor),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
// Default `crossAxisAlignment: center` — Icon links, Mengen-
// Anzeige rechts und der Aktions-Menü-Button sitzen damit
// vertikal mittig zum Inhalts-Block in der Mitte. Wirkt
// ausgeglichener, wenn der Subtitle mehrere Zeilen hat
// (Art-Nr, Lager, Komponente, Reason).
children: [
Icon(leadingIcon, color: leadingIconColor, size: 28),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
'${item.isComponent ? '' : ''}${_articleTitle(article)}',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: titleColor,
// Entfernte Positionen werden im Detail-Screen
// **nicht** rausgefiltert, sondern bleiben
// sichtbar — durchgestrichen, damit der Fahrer
// erkennt „hatten wir, ist raus" und über das
// Aktions-Menü ggf. wiederherstellen kann.
decoration: item.isRemoved
? TextDecoration.lineThrough
: null,
),
),
),
if (statusBadgeLabel != null)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: titleColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: titleColor),
),
child: Text(
statusBadgeLabel,
style: TextStyle(
color: titleColor,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 2),
Text(
'Art.-Nr.: ${article?.articleNumber ?? '⟨unbekannt⟩'}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
// Lager-Angabe nur für scanbare Artikel. Dienstleistungen
// (`scanNotRequired`) werden nicht aus einem Lager beladen
// → die Lager-Zeile wäre irrelevant und entfällt.
if (warehouse != null && !scanNotRequired)
Text(
'Lager: ${warehouse.name}'
'${isExternalWarehouse ? ' (Filiale)' : ''}',
style: TextStyle(
fontSize: 12,
color: isExternalWarehouse
? Colors.amber.shade800
: theme.colorScheme.onSurfaceVariant,
fontWeight: isExternalWarehouse
? FontWeight.w600
: FontWeight.normal,
),
),
if (item.isHeld &&
item.scanProgress.heldReason != null &&
item.scanProgress.heldReason!.isNotEmpty)
Text(
'Grund: ${item.scanProgress.heldReason}',
style: TextStyle(
fontSize: 12,
color: Colors.orange.shade800,
),
),
],
),
),
// Mengen-Zähler nur für scanbare Artikel. Dienstleistungen
// (`scanNotRequired`) werden nicht gescannt → eine „X / Y"-
// Anzeige wäre dort sinnlos und wird weggelassen.
if (!scanNotRequired) ...[
const SizedBox(width: 8),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${item.scanProgress.scannedQuantity} / ${item.requiredQuantity}',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: quantityColor,
),
),
// Grünes Häkchen sobald die Soll-Menge erreicht ist —
// schneller visueller Anker als die Zahlen-Differenz.
if (item.isDone) ...[
const SizedBox(width: 4),
Icon(
Icons.check_circle,
size: 20,
color: Colors.green.shade700,
),
],
],
),
],
),
],
_ItemActionMenu(item: item, onSelected: onAction),
],
),
if (canManualConfirm) ...[
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => onAction(_ItemAction.manualConfirm),
icon: const Icon(Icons.check_circle_outline, size: 18),
label: const Text('Manuell als geladen bestätigen'),
),
),
],
// Dienstleistung (nicht-scanbar): Hinweis statt Scan/Manuell-
// Aktion. Steht an derselben Stelle wie der Manuell-Button.
if (scanNotRequired) ...[
const SizedBox(height: 8),
const _ScanNotRequiredHint(),
],
],
),
),
),
);
}
String _articleTitle(Article? article) {
if (article == null) return item.articleId;
return article.name;
}
}
/// 3-Punkte-Menü am Ende einer Item-Row. Welche Aktion sichtbar ist,
/// hängt vom aktuellen ScanStatus ab:
/// * `removed` → „Wiederherstellen"
/// * sonst → „Entfernen"
///
/// Item-Pausieren wird vom UI bewusst **nicht** angeboten — das Backend
/// kennt die Hold-Action zwar weiterhin, in der Fahrer-App ist sie aber
/// nicht mehr Teil des Workflows.
class _ItemActionMenu extends StatelessWidget {
const _ItemActionMenu({required this.item, required this.onSelected});
final DeliveryItem item;
final void Function(_ItemAction) onSelected;
@override
Widget build(BuildContext context) {
return PopupMenuButton<_ItemAction>(
tooltip: 'Artikel-Aktionen',
icon: const Icon(Icons.more_vert),
onSelected: onSelected,
itemBuilder: (_) {
if (item.isRemoved) {
return const [
PopupMenuItem(
value: _ItemAction.unremove,
child: ListTile(
leading: Icon(Icons.restore, color: Colors.green),
title: Text('Wiederherstellen'),
contentPadding: EdgeInsets.zero,
),
),
];
}
return const [
PopupMenuItem(
value: _ItemAction.remove,
child: ListTile(
leading: Icon(Icons.delete_outline, color: Colors.red),
title: Text('Entfernen'),
contentPadding: EdgeInsets.zero,
),
),
];
},
);
}
}