Final commit.

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

View File

@ -1,75 +1,100 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/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_state.dart';
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/feature/loading/model/loading_group.dart';
import 'package:hl_lieferservice/feature/loading/presentation/loading_customer_page.dart';
import 'package:hl_lieferservice/feature/loading/util/loading_order.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
/// Übersichts-Ansicht für die Beladen-Phase: alle Kunden in Beladereihen-
/// folge mit Fortschritts-Status. KEIN Scanner — der Scanner-Fokus bleibt
/// auf der [LoadingCustomerPage]. Tap auf einen Kunden öffnet seine
/// Vollbild-Ansicht mit dem entsprechenden Index.
/// Übersichts-Ansicht für die Beladen-Phase: alle Kunden mit ihren
/// Artikeln und Soll/Ist-Mengen.
///
/// Scans werden in der `LoadingCustomerPage` (Vollbild pro Kunde)
/// ausgelöst; diese Übersicht ist reines Status-Display und Navigation.
/// Der Button „Auslieferungs-Phase starten" unten erlaubt dem Fahrer,
/// jederzeit in die nächste Phase zu wechseln — die App erzwingt
/// bewusst keine 100%-Auslieferung (z. B. wenn ein Artikel fehlt und
/// der Fahrer das später hold/cancel-en will).
class LoadingOverviewPage extends StatelessWidget {
const LoadingOverviewPage({super.key});
String? _lookupCarPlate(String? carId, Tour tour) {
String? _plateFor(BuildContext context, String? carId) {
if (carId == null) return null;
return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate;
final state = context.read<CarsBloc>().state;
if (state is! CarsLoaded) return null;
for (final c in state.cars) {
if (c.id == carId) return c.plate;
}
return null;
}
List<LoadingGroup> _buildGroups(TourLoaded state, String carIdStr) {
final orderedIds = LoadingOrder.computeForCar(
state: state,
carIdStr: carIdStr,
);
final byId = {for (final d in state.tour.deliveries) d.id: d};
final groups = <LoadingGroup>[];
for (final id in orderedIds) {
final delivery = byId[id];
if (delivery == null) continue;
if (delivery.state == DeliveryState.finished) continue;
final scannable =
delivery.articles.where((a) => a.scannable).toList(growable: false);
if (scannable.isEmpty && delivery.state != DeliveryState.canceled) {
continue;
}
groups.add(LoadingGroup(
delivery: delivery,
articles: scannable,
carPlate: _lookupCarPlate(delivery.carId, state.tour),
));
}
return groups;
/// Beladereihenfolge = umgekehrte Sortier-Reihenfolge: wer zuletzt
/// ausgeliefert wird, wird zuerst beladen (kommt unten in den LKW).
List<Delivery> _ownDeliveriesInLoadingOrder(
TourDetails details,
String carId,
bool multiCarTeam,
) {
final relevant = multiCarTeam
? details.deliveriesSorted
.where((d) => d.assignedCarId == carId)
.toList()
: details.deliveriesSorted;
return relevant.reversed.toList();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CarSelectBloc, CarSelectState>(
builder: (context, carState) {
final carIdStr =
carState is CarSelectComplete ? carState.selectedCar.id.toString() : "";
final carId =
carState is CarSelectComplete ? carState.selectedCar.id : '';
return BlocBuilder<TourBloc, TourState>(
builder: (context, tourState) {
if (tourState is TourLoadingFailed) {
if (tourState is TourLoadFailed) {
return const DeliveryLoadingFailedPage();
}
if (tourState is TourEmpty) {
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: AppBar(title: const Text('Beladung')),
body: const _EmptyOverview(),
);
}
if (tourState is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final groups = _buildGroups(tourState, carIdStr);
final carsState = context.read<CarsBloc>().state;
final multiCarTeam =
carsState is CarsLoaded && carsState.cars.length >= 2;
final ordered = _ownDeliveriesInLoadingOrder(
tourState.details,
carId,
multiCarTeam,
);
// Gate für „Auslieferungs-Phase starten": jede aktive Lieferung
// muss im Standardlager fertig beladen sein. Pausierte/abgebrochene
// zählen nicht; offene Filial-Items dürfen bleiben (werden unterwegs
// geholt — siehe Status-Logik). Keine aktive Lieferung ⇒ kein Block.
final canStart = ordered
.where((d) => d.state == DeliveryState.active)
.every(tourState.details.standardWarehouseLoadingDone);
return Scaffold(
drawer: const HomeAppDrawer(),
@ -77,15 +102,20 @@ class LoadingOverviewPage extends StatelessWidget {
preferredSize: const Size.fromHeight(140),
child: PhaseStepper(
currentPhase: DeliveryPhase.beladen,
carId: carIdStr,
carId: carId,
),
),
body: SafeArea(
top: false,
child: groups.isEmpty
child: ordered.isEmpty
? const _EmptyOverview()
: _OverviewList(groups: groups),
: _OverviewList(
deliveries: ordered,
details: tourState.details,
plateResolver: (id) => _plateFor(context, id),
),
),
bottomNavigationBar: _BottomBar(carId: carId, canStart: canStart),
);
},
);
@ -95,18 +125,104 @@ class LoadingOverviewPage extends StatelessWidget {
}
class _OverviewList extends StatelessWidget {
const _OverviewList({required this.groups});
const _OverviewList({
required this.deliveries,
required this.details,
required this.plateResolver,
});
final List<LoadingGroup> groups;
final List<Delivery> deliveries;
final TourDetails details;
final String? Function(String?) plateResolver;
/// Klassifiziert eine Lieferung in einen UX-Bucket. Bucket bestimmt,
/// in welcher Sektion das Tile in der Übersicht landet.
///
/// Lifecycle-Zustände (`held` / `canceled`) gewinnen vor der
/// Standardlager-/Filial-Logik: eine pausierte oder abgebrochene
/// Lieferung soll nicht versehentlich als „nächste Lieferung" oder
/// „offen" auftauchen, auch wenn ihr Standardlager-Counter Items
/// hätte.
_OverviewBucket _bucketOf(Delivery delivery) {
if (delivery.state == DeliveryState.canceled) {
return _OverviewBucket.canceled;
}
if (delivery.state == DeliveryState.held) {
return _OverviewBucket.paused;
}
final standardDone = details.standardWarehouseLoadingDone(delivery);
final hasExternal = details.hasExternalWarehouseItems(delivery);
if (standardDone && hasExternal) return _OverviewBucket.later;
if (standardDone) return _OverviewBucket.done;
return _OverviewBucket.open;
}
@override
Widget build(BuildContext context) {
final totalActive = groups
.where((g) => g.delivery.state != DeliveryState.canceled)
// Standardlager-Items pro Lieferung — Basis für den Fortschritts-
// Counter („X / Y Artikel" am Kunden). Filial-Items zählen hier
// nicht; sie werden separat geladen und sollen den Fortschritt im
// Hauptlager nicht verwässern.
final standardItemsPerDelivery = <String, List<DeliveryItem>>{};
for (final d in deliveries) {
standardItemsPerDelivery[d.id] = d.items.where((it) {
if (it.isRemoved) return false;
if (!details.isArticleScannable(it.articleId)) return false;
final w = details.warehouseOf(it.warehouseId);
return w?.isStandard ?? false;
}).toList();
}
// Lieferungen in drei Buckets sortieren. Innerhalb der Buckets
// bleibt die Belade-Reihenfolge erhalten (Original-Index), damit der
// Fahrer „Kunde Nr. 3 in der Reihenfolge" jederzeit identifizieren
// kann — auch wenn die Karte gerade in „Später abholen" einsortiert
// ist.
final open = <_OverviewEntry>[];
final later = <_OverviewEntry>[];
final paused = <_OverviewEntry>[];
final done = <_OverviewEntry>[];
final canceled = <_OverviewEntry>[];
for (int i = 0; i < deliveries.length; i++) {
final d = deliveries[i];
final entry = _OverviewEntry(
originalIndex: i,
delivery: d,
standardItems: standardItemsPerDelivery[d.id] ?? const [],
);
switch (_bucketOf(d)) {
case _OverviewBucket.open:
open.add(entry);
case _OverviewBucket.later:
later.add(entry);
case _OverviewBucket.paused:
paused.add(entry);
case _OverviewBucket.done:
done.add(entry);
case _OverviewBucket.canceled:
canceled.add(entry);
}
}
// „Nächste Lieferung" zieht den ersten Offen-Eintrag heraus und
// präsentiert ihn in einer eigenen Sektion ganz oben — der Fahrer
// sieht damit immer sofort, was als nächstes anliegt, statt aus
// einer Liste die Position-Nr. 1 selbst rauszusuchen. Sobald die
// Lieferung beladen ist, rutscht sie in „Später abholen" oder
// „Fertig" und der Eintrag auf Position 2 wird zur neuen
// „Nächsten" — automatisch, da diese Logik bei jedem Build neu
// läuft.
final _OverviewEntry? nextUp = open.isEmpty ? null : open.removeAt(0);
final totalActive = deliveries
.where((d) => d.state != DeliveryState.canceled)
.length;
final doneActive = groups
.where((g) =>
g.delivery.state != DeliveryState.canceled && g.isComplete)
// „Fertig beladen" = Standardlager fertig. Filial-Items
// blockieren den Übergang in die Auslieferungs-Phase nicht.
final doneActive = deliveries
.where((d) =>
d.state != DeliveryState.canceled &&
details.standardWarehouseLoadingDone(d))
.length;
return Column(
@ -120,18 +236,42 @@ class _OverviewList extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Beladereihenfolge",
'Beladereihenfolge',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
"$doneActive / $totalActive Kunden",
'$doneActive / $totalActive Kunden',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 6),
const SizedBox(height: 4),
// Macht den Bezugsrahmen der Phase explizit: Es wird im
// Standardlager beladen. Filial-Artikel sind die Ausnahme
// und werden separat geholt — sonst entsteht der Eindruck,
// man müsse für jede Lieferung mehrere Lager ansteuern.
Row(
children: [
Icon(
Icons.inventory_2_outlined,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
'Beladung im Standardlager · Filial-Artikel werden separat geholt',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
@ -140,11 +280,6 @@ class _OverviewList extends StatelessWidget {
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
doneActive == totalActive && totalActive > 0
? Colors.green
: Theme.of(context).primaryColor,
),
),
),
],
@ -152,27 +287,64 @@ class _OverviewList extends StatelessWidget {
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.only(top: 8, bottom: 16),
itemCount: groups.length,
itemBuilder: (context, index) {
final g = groups[index];
return _OverviewTile(
position: index + 1,
group: g,
onTap: () {
// Push (kein pushReplacement): die Übersicht ist seit dem
// Routing-Umbau in home.dart die Wurzel der Beladen-Phase.
// Vom Vollbild kehrt der Fahrer per pop zurück auf diese
// Übersicht — der Stack bleibt damit flach.
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LoadingCustomerPage(initialIndex: index),
),
);
},
);
},
child: ListView(
padding: const EdgeInsets.only(top: 4, bottom: 16),
children: [
if (nextUp != null)
_BucketSection(
title: 'Nächste Lieferung',
count: 1,
color: Theme.of(context).colorScheme.primary,
icon: Icons.local_shipping_outlined,
entries: [nextUp],
details: details,
),
if (open.isNotEmpty)
_BucketSection(
title: 'Offen',
count: open.length,
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.55),
entries: open,
details: details,
),
if (later.isNotEmpty)
_BucketSection(
title: 'Später abholen',
count: later.length,
color: Colors.amber.shade800,
entries: later,
details: details,
),
if (paused.isNotEmpty)
_BucketSection(
title: 'Pausiert',
count: paused.length,
color: Colors.orange.shade800,
icon: Icons.pause_circle_outline,
entries: paused,
details: details,
),
if (done.isNotEmpty)
_BucketSection(
title: 'Fertig',
count: done.length,
color: Colors.green.shade700,
entries: done,
details: details,
),
if (canceled.isNotEmpty)
_BucketSection(
title: 'Abgebrochen',
count: canceled.length,
color: Colors.red.shade700,
icon: Icons.cancel_outlined,
entries: canceled,
details: details,
),
],
),
),
],
@ -183,81 +355,138 @@ class _OverviewList extends StatelessWidget {
class _OverviewTile extends StatelessWidget {
const _OverviewTile({
required this.position,
required this.group,
required this.delivery,
required this.standardItems,
required this.details,
required this.onTap,
});
final int position;
final LoadingGroup group;
final Delivery delivery;
/// Scanbare, nicht-entfernte Items aus dem Standardlager. Basis für
/// den Beladen-Fortschritt — Filial-Items werden separat geladen
/// und zählen hier nicht mit.
final List<DeliveryItem> standardItems;
final TourDetails details;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final canceled = group.delivery.state == DeliveryState.canceled;
final isComplete = group.isComplete;
final isPartial = group.isPartial;
final hasExternalWarehouse = group.hasExternalWarehouseArticles;
final canceled = delivery.state == DeliveryState.canceled;
final held = delivery.state == DeliveryState.held;
final standardDone = details.standardWarehouseLoadingDone(delivery);
final hasExternal = details.hasExternalWarehouseItems(delivery);
final externalLabels = details.externalWarehouseLabels(delivery);
final scannedStandardCount =
standardItems.where((it) => it.isDone).length;
final scannedAnyStandard =
standardItems.any((it) => it.scanProgress.scannedQuantity > 0);
final customer = details.customerOf(delivery);
// cardColor und borderColor sind nicht final, weil das Außenlager-
// Highlight sie weiter unten ggf. überschreibt.
Color cardColor;
Color borderColor;
final Color titleColor;
final String statusText;
final IconData statusIcon;
Color titleColor;
String statusText;
IconData statusIcon;
// Lifecycle-States gewinnen vor der Beladen-Logik: pausiert /
// abgebrochen sollen visuell sofort als solche erkennbar sein —
// egal wie weit der Standardlager-Counter war.
if (canceled) {
cardColor = Colors.grey.withValues(alpha: 0.08);
borderColor = Colors.grey.withValues(alpha: 0.35);
titleColor = Colors.grey.shade700;
statusText = "Abgebrochen";
cardColor = Colors.red.withValues(alpha: 0.06);
borderColor = Colors.red.withValues(alpha: 0.35);
titleColor = Colors.red.shade700;
statusText = 'Abgebrochen';
statusIcon = Icons.cancel_outlined;
} else if (isComplete && hasExternalWarehouse) {
// Standardlager ist fertig, aber es liegen noch Artikel in einem
// anderen Lager — die Lieferung ist also NICHT komplett beladen.
// Wir machen das im Status-Text explizit, damit der Fahrer nicht
// fälschlich davon ausgeht, dass nichts mehr offen ist.
cardColor = Colors.deepOrange.withValues(alpha: 0.10);
borderColor = Colors.deepOrange.withValues(alpha: 0.45);
titleColor = Colors.deepOrange.shade800;
statusText = "Standardlager fertig — Außenlager offen";
} else if (held) {
cardColor = Colors.orange.withValues(alpha: 0.07);
borderColor = Colors.orange.withValues(alpha: 0.45);
titleColor = Colors.orange.shade800;
statusText = 'Pausiert';
statusIcon = Icons.pause_circle_outline;
} else if (standardDone && hasExternal) {
// Sonderfall, fachlich wichtig: Standardlager ist durch, aber im
// Filiale wartet noch was. Der Fahrer kann technisch in die
// Auslieferungs-Phase, muss aber wissen, dass er zwischendurch
// ein zweites Lager ansteuert.
cardColor = Colors.amber.withValues(alpha: 0.18);
borderColor = Colors.amber.withValues(alpha: 0.55);
titleColor = Colors.amber.shade800;
statusText = 'Standardlager fertig — Filiale offen';
statusIcon = Icons.warehouse_outlined;
} else if (isComplete) {
} else if (standardDone) {
cardColor = Colors.green.withValues(alpha: 0.07);
borderColor = Colors.green.withValues(alpha: 0.35);
titleColor = Colors.green.shade700;
statusText = "Fertig beladen";
statusText = 'Fertig beladen';
statusIcon = Icons.check_circle_outline;
} else if (isPartial) {
} else if (scannedAnyStandard) {
cardColor = Colors.orange.withValues(alpha: 0.07);
borderColor = Colors.orange.withValues(alpha: 0.35);
titleColor = Colors.orange.shade800;
statusText = "Beladung läuft";
statusText = 'Beladung läuft';
statusIcon = Icons.pending_outlined;
} else {
cardColor = theme.colorScheme.surfaceContainerLow;
borderColor = Colors.transparent;
titleColor = theme.colorScheme.onSurface;
statusText = "Offen";
statusText = 'Offen';
statusIcon = Icons.radio_button_unchecked;
}
// Außenlager-Hervorhebung: lebt unabhängig vom Scan-Status. Eine
// abgebrochene Lieferung bleibt grau, ansonsten überschreibt das
// Außenlager-Highlight die Standard-Farben durch ein klar erkennbares
// Orange — der Fahrer muss früh genug wissen, dass er ein anderes
// Lager anfahren wird. Der Sonderzweig "isComplete && hasExternal-
// Warehouse" oben hat das Highlight schon gesetzt, hier greift es
// für die noch nicht fertigen Fälle.
if (!canceled && hasExternalWarehouse && !isComplete) {
cardColor = Colors.deepOrange.withValues(alpha: 0.10);
borderColor = Colors.deepOrange.withValues(alpha: 0.65);
// Bewusst KEIN Filial-Card-Highlight, solange das Standardlager
// noch offen ist: eine Lieferung mit Standard- UND Filial-Items
// soll sich nicht von einer reinen Standardlager-Lieferung
// unterscheiden — der Fahrer belädt zuerst das Standardlager, und eine
// orange Karte würde fälschlich einen Sonderzustand suggerieren
// (irreführend besonders in der „Nächste Lieferung"-Sektion). Den
// Hinweis aufs Filiale trägt allein das `_ExternalWarehouseBadge`
// weiter unten. Der echte Sonderfall „Standardlager fertig — Filiale
// offen" wird oben in der Status-Kette eigenständig amber gefärbt.
// Fortschritts-Label rechts in der Status-Zeile.
//
// „X / Y Artikel" bezieht sich bewusst nur auf das Standardlager (siehe
// `standardItems`). Hat eine Lieferung dort GAR KEINE Position, ergäbe das
// ein irreführendes „0 / 0 Artikel" — die Card sähe leer aus, obwohl die
// Ware nur aus einer Filiale kommt (später abholen) oder es sich um eine
// reine Dienstleistung handelt. In diesen Fällen zeigen wir statt des
// Zählers einen sprechenden Hinweis. So wirkt keine Card mehr „verloren".
final String progressLabel;
if (canceled || held) {
progressLabel = '';
} else if (standardItems.isNotEmpty) {
progressLabel = '$scannedStandardCount / ${standardItems.length} Artikel';
} else if (hasExternal) {
// Keine Standardlager-Ware, aber Filial-Items → wird separat geholt.
// Das Filial-Badge unten trägt die Details; hier nur der Kurz-Hinweis.
progressLabel = 'Nur Filiale';
} else if (details.hasServiceItems(delivery)) {
// Weder Standardlager- noch Filial-Ware, aber eine nicht-scanbare
// Position → reine Dienstleistung. Rechtfertigt trotzdem die Anfahrt.
progressLabel = 'Nur Dienstleistung';
} else {
// Defensive: keinerlei relevante Positionen — sollte praktisch nicht
// vorkommen. Kein „0 / 0", sondern neutraler Strich.
progressLabel = '';
}
final progressLabel = canceled
? ""
: "${group.completeArticles}/${group.totalArticles} Artikel";
// Avatar-Hintergrund spiegelt den Lifecycle-State wider, damit die
// Nummer-Bubble nicht weiterhin primary leuchtet, obwohl die
// Lieferung pausiert/abgebrochen ist.
final Color avatarColor;
if (canceled) {
avatarColor = Colors.red.shade700;
} else if (held) {
avatarColor = Colors.orange.shade800;
} else {
avatarColor = theme.colorScheme.primary;
}
final reason = delivery.stateReason;
final showReason = (canceled || held) && reason != null && reason.isNotEmpty;
return Opacity(
opacity: canceled ? 0.65 : 1.0,
@ -277,12 +506,11 @@ class _OverviewTile extends StatelessWidget {
child: Row(
children: [
CircleAvatar(
backgroundColor:
canceled ? Colors.grey : theme.colorScheme.primary,
backgroundColor: avatarColor,
foregroundColor: theme.colorScheme.onPrimary,
radius: 18,
child: Text(
"$position",
'$position',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
@ -292,7 +520,7 @@ class _OverviewTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.delivery.customer.name,
customer?.name ?? '⟨Unbekannter Kunde⟩',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
@ -304,7 +532,7 @@ class _OverviewTile extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
group.delivery.customer.address.toString(),
delivery.deliveryAddressSnapshot.oneLine,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
@ -320,9 +548,6 @@ class _OverviewTile extends StatelessWidget {
size: 14, color: titleColor),
),
const SizedBox(width: 4),
// Expanded, damit lange Status-Texte wie
// "Standardlager fertig — Außenlager offen"
// umbrechen statt zu überlaufen.
Expanded(
child: Text(
statusText,
@ -344,11 +569,25 @@ class _OverviewTile extends StatelessWidget {
),
],
),
if (!canceled && hasExternalWarehouse) ...[
const SizedBox(height: 6),
_ExternalWarehouseBadge(
labels: group.externalWarehouseLabels,
if (showReason)
Padding(
padding: const EdgeInsets.only(left: 18, top: 2),
child: Text(
'Grund: $reason',
style: TextStyle(
fontSize: 11,
color: titleColor,
),
),
),
// Filial-Badge nur sichtbar, wenn die Lieferung
// im aktiven Workflow ist. Pausiert / abgebrochen
// soll keine zusätzliche Filial-Aufmerksamkeit
// ziehen — die Aktion ist gerade unterbrochen oder
// beendet.
if (!canceled && !held && hasExternal) ...[
const SizedBox(height: 6),
_ExternalWarehouseBadge(labels: externalLabels),
],
],
),
@ -363,9 +602,201 @@ class _OverviewTile extends StatelessWidget {
}
}
/// Hinweis-Badge unter dem Status-Row einer Lieferung mit Artikeln aus
/// einem oder mehreren Außenlagern. Listet die betroffenen Lager-Namen
/// auf, damit der Fahrer beim Beladen weiß, wohin er zusätzlich muss.
class _BottomBar extends StatelessWidget {
const _BottomBar({required this.carId, required this.canStart});
final String carId;
/// Nur wenn alle aktiven Lieferungen im Standardlager fertig beladen sind,
/// darf der Fahrer in die Auslieferungs-Phase wechseln.
final bool canStart;
@override
Widget build(BuildContext context) {
if (carId.isEmpty) return const SizedBox.shrink();
final theme = Theme.of(context);
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!canStart)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.info_outline,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Flexible(
child: Text(
'Erst alle Artikel des Standardlagers beladen',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: canStart
? () {
context.read<PhaseBloc>().add(
PhaseSet(
carId: carId,
phase: DeliveryPhase.ausliefern,
),
);
}
: null,
icon: const Icon(Icons.arrow_forward),
label: const Text('Auslieferungs-Phase starten'),
),
),
],
),
),
);
}
}
/// UX-Bucket der Übersicht. Bestimmt, in welche Sektion eine Lieferung
/// einsortiert wird:
/// * `open` — Standardlager nicht fertig (= jetzt zu beladen)
/// * `later` — Standardlager fertig, aber Filial-Items offen
/// * `paused` — Lieferung explizit pausiert (`held`)
/// * `done` — komplett fertig (Standardlager fertig & kein Filiale)
/// * `canceled` — endgültig abgebrochen
enum _OverviewBucket { open, later, paused, done, canceled }
/// Bündelt eine Lieferung mit ihrer Beladereihenfolge-Position für die
/// Übersicht — der `originalIndex` bleibt über die Sektions-Umsortierung
/// hinweg sichtbar im Tile-Avatar.
class _OverviewEntry {
const _OverviewEntry({
required this.originalIndex,
required this.delivery,
required this.standardItems,
});
final int originalIndex;
final Delivery delivery;
final List<DeliveryItem> standardItems;
}
/// Sektion in der Übersicht mit eigenem Header + zugehörigen Tiles.
/// Header zeigt Titel, farbigen Pill mit Anzahl und nimmt die Section-
/// Farbe als Akzent — Fahrer erkennt auf einen Blick, „was kommt jetzt".
class _BucketSection extends StatelessWidget {
const _BucketSection({
required this.title,
required this.count,
required this.color,
required this.entries,
required this.details,
this.icon,
});
final String title;
final int count;
final Color color;
final List<_OverviewEntry> entries;
final TourDetails details;
/// Optionales Icon vor dem Titel — wird aktuell nur für „Nächste
/// Lieferung" verwendet, damit diese Sektion auf einen Blick als
/// „aktiv jetzt" erkennbar ist. Bei den restlichen Sektionen reichen
/// Farb-Balken + Pill als visueller Anker.
final IconData? icon;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Row(
children: [
Container(
width: 4,
height: 18,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
if (icon != null) ...[
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
],
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: color,
letterSpacing: 0.4,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 1),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$count',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
),
],
),
),
for (final entry in entries)
_OverviewTile(
position: entry.originalIndex + 1,
delivery: entry.delivery,
standardItems: entry.standardItems,
details: details,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LoadingCustomerPage(
initialIndex: entry.originalIndex,
),
),
);
},
),
],
);
}
}
/// Hinweis-Badge unterhalb der Status-Zeile einer Lieferung mit Artikeln
/// aus einem oder mehreren Filialen.
///
/// Formuliert bewusst „Enthält auch … zum späteren Abholen": Die Lieferung
/// wird JETZT normal (im Standardlager) beladen — das Filial-Item ist
/// nur ein zusätzlicher Bestandteil. Das „auch" signalisiert den Zusatz,
/// und „zum späteren Abholen" hängt den Zeitbezug klar an den ARTIKEL, nicht
/// an die Lieferung (die sonst fälschlich als nach-hinten-geschoben wirkt).
class _ExternalWarehouseBadge extends StatelessWidget {
const _ExternalWarehouseBadge({required this.labels});
@ -373,33 +804,44 @@ class _ExternalWarehouseBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final text = labels.isEmpty
? "Außenlager"
: "Außenlager: ${labels.join(", ")}";
final lagerText = labels.isEmpty
? 'Artikel zum späteren Abholen aus der Filiale'
: 'Artikel zum späteren Abholen aus Filiale: ${labels.join(", ")}';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: Colors.deepOrange.withValues(alpha: 0.15),
color: Colors.amber.withValues(alpha: 0.22),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.deepOrange.withValues(alpha: 0.6)),
border: Border.all(color: Colors.amber.withValues(alpha: 0.7)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.warehouse_outlined,
size: 14, color: Colors.deepOrange.shade700),
Icon(
Icons.warehouse_outlined,
size: 14,
color: Colors.amber.shade800,
),
const SizedBox(width: 4),
Flexible(
child: Text(
text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.deepOrange.shade800,
),
child: RichText(
maxLines: 2,
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.amber.shade800,
),
children: [
const TextSpan(
text: 'Enthält auch ',
style: TextStyle(fontWeight: FontWeight.w800),
),
TextSpan(text: lagerText),
],
),
),
),
],
@ -421,7 +863,7 @@ class _EmptyOverview extends StatelessWidget {
Icon(Icons.inbox_outlined, size: 64, color: scheme.onSurfaceVariant),
const SizedBox(height: 12),
Text(
"Keine Lieferungen zum Beladen",
'Keine Lieferungen zum Beladen',
style: Theme.of(context).textTheme.titleMedium,
),
],