Ein nicht-scanbarer Set-Kopf (Parent-Artikel) wird im Beladen-Screen über seinen Komponenten gezeigt. Der Hinweis „Kein Scanvorgang notwendig" ist dort irreführend, weil die (scanbaren) Komponenten darunter sehr wohl gescannt werden. _ItemRow bekommt suppressScanHint; gesetzt nur für Set-Köpfe (Artikelnummer wird von einer Komponente als parentArtikelNr referenziert). Komponenten/echte Dienstleistungen behalten den Hinweis. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1484 lines
56 KiB
Dart
1484 lines
56 KiB
Dart
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();
|
||
|
||
// ── Set-Köpfe (Parent-Komponenten) immer mit ihrem Set anzeigen ──────
|
||
// Ein nicht-scanbarer Set-Kopf (Stücklisten-Oberartikel, z. B. als
|
||
// Pauschale geführt) soll NICHT isoliert unter „Dienstleistungen"
|
||
// erscheinen, sondern als Kopf über seinen (scanbaren) Komponenten in der
|
||
// jeweiligen Lagergruppe. Ein Item ist Set-Kopf, wenn seine Artikelnummer
|
||
// von einer Komponente als parentArtikelNr referenziert wird.
|
||
String? artNrOf(DeliveryItem it) =>
|
||
details.articleOf(it.articleId)?.articleNumber;
|
||
// Alle Set-Köpfe (Parent-Artikel): ein Item, dessen Artikelnummer von einer
|
||
// Komponente als parentArtikelNr referenziert wird. Für nicht-scanbare
|
||
// Set-Köpfe wird der „kein Scanvorgang notwendig"-Hinweis unterdrückt.
|
||
final componentParentNrs = delivery.items
|
||
.where((it) => it.isComponent)
|
||
.map((it) => it.parentArtikelNr)
|
||
.whereType<String>()
|
||
.toSet();
|
||
final setParentIds = delivery.items
|
||
.where((it) {
|
||
final nr = artNrOf(it);
|
||
return nr != null && componentParentNrs.contains(nr);
|
||
})
|
||
.map((it) => it.id)
|
||
.toSet();
|
||
// Set-Köpfe je Lagergruppe (warehouseId → einzuhängende Köpfe) +
|
||
// gesammelte IDs, um sie aus der Dienstleistungs-Sektion zu entfernen.
|
||
final injectedParentsByWarehouseId = <String, List<DeliveryItem>>{};
|
||
final injectedParentIds = <String>{};
|
||
for (final group in groups) {
|
||
final groupParentNrs = group.items
|
||
.where((it) => it.isComponent)
|
||
.map((it) => it.parentArtikelNr)
|
||
.whereType<String>()
|
||
.toSet();
|
||
if (groupParentNrs.isEmpty) continue;
|
||
final parents = serviceItems
|
||
.where((s) => groupParentNrs.contains(artNrOf(s)))
|
||
.toList();
|
||
if (parents.isEmpty) continue;
|
||
injectedParentsByWarehouseId[group.warehouse.id] = parents;
|
||
injectedParentIds.addAll(parents.map((p) => p.id));
|
||
}
|
||
// Set-Köpfe, die in eine Gruppe eingehängt wurden, nicht doppelt unter
|
||
// „Dienstleistungen" zeigen. Set-Köpfe ohne scanbare Komponenten (kommen
|
||
// hier nicht vor) blieben weiterhin in der Liste.
|
||
final serviceItemsView = serviceItems
|
||
.where((s) => !injectedParentIds.contains(s.id))
|
||
.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,
|
||
),
|
||
// Nicht-scanbare Set-Köpfe dieser Gruppe vor ihre Komponenten
|
||
// einhängen; `_parentFirst` ordnet Kopf vor Komponenten.
|
||
for (final item in _parentFirst([
|
||
...?injectedParentsByWarehouseId[group.warehouse.id],
|
||
...group.items,
|
||
]))
|
||
_ItemRow(
|
||
item: item,
|
||
details: details,
|
||
onAction: (action) => onItemAction(item, action),
|
||
suppressScanHint: setParentIds.contains(item.id),
|
||
),
|
||
],
|
||
// Gebuchte Dienstleistungen (nicht-scanbare Positionen ohne
|
||
// eigene Set-Komponenten): 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.
|
||
if (serviceItemsView.isNotEmpty) ...[
|
||
const _ServiceSectionHeader(),
|
||
for (final item in _parentFirst(serviceItemsView))
|
||
_ItemRow(
|
||
item: item,
|
||
details: details,
|
||
onAction: (action) => onItemAction(item, action),
|
||
suppressScanHint: setParentIds.contains(item.id),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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.suppressScanHint = false,
|
||
});
|
||
|
||
final DeliveryItem item;
|
||
final TourDetails details;
|
||
final void Function(_ItemAction action) onAction;
|
||
|
||
/// Unterdrückt den „Kein Scanvorgang notwendig"-Hinweis. Für nicht-scanbare
|
||
/// **Set-Köpfe** (Parent-Artikel): dort ist der Hinweis irreführend, weil die
|
||
/// (scanbaren) Komponenten darunter sehr wohl gescannt werden.
|
||
final bool suppressScanHint;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final article = details.articleOf(item.articleId);
|
||
final warehouse = details.warehouseOf(item.warehouseId);
|
||
// `true` für nicht-scanbare Positionen (Dienstleistungen / Pauschalen /
|
||
// Set-Köpfe): die Card wird GENAU SO wie ein scanbarer Artikel gerendert,
|
||
// bekommt aber unten den Hinweis „Kein Scanvorgang notwendig" und keinen
|
||
// Mengen-Zähler. Aus dem Artikel abgeleitet, damit ein in eine Lagergruppe
|
||
// eingehängter nicht-scanbarer Set-Kopf automatisch korrekt rendert.
|
||
final scanNotRequired = !(article?.scannable ?? false);
|
||
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.
|
||
// Bei Set-Köpfen (Parent-Artikel) bewusst unterdrückt — dort
|
||
// wäre „kein Scanvorgang notwendig" irreführend.
|
||
if (scanNotRequired && !suppressScanHint) ...[
|
||
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,
|
||
),
|
||
),
|
||
];
|
||
},
|
||
);
|
||
}
|
||
}
|