Files
Holzleitner-Lieferservice-App/lib/feature/loading/presentation/loading_overview_page.dart
Dennis Nemec 857c92031a feat: abweichende Lieferadresse und Empfänger vor Ort anzeigen
Belege können im ERP eine eigene Lieferadresse (LieferAdressId) mit
anderer Person tragen als der Besteller. Die App zeigt jetzt überall den
Empfänger vor Ort:

- TourDetails.recipientOf liefert Empfänger (Name aus der Lieferadresse,
  sonst Besteller), Lieferanschrift und differsFromOrderer.
- Sortieren, Auswählen, Beladen und Liefer-Übersicht: Empfängername +
  Lieferadresse, bei Abweichung „Bestellt von: <Kunde>".
- Lieferdetails: hervorgehobene Karte „Abweichende Lieferadresse" mit
  Empfänger, Anschrift und Kontakt; Kunden-Block als „Besteller (Kunde)"
  mit Adresse, Kundennummer und Kontaktdaten.
- Maps navigiert zur Lieferadresse statt zur Kundenadresse; AppBar,
  Unterschrift und Beladen-Avatar nutzen den Empfänger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:51:31 +02:00

920 lines
34 KiB
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_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/delivery/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/feature/loading/presentation/loading_customer_page.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
import 'package:hl_lieferservice/widget/orderer_hint.dart';
/// Ü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? _plateFor(BuildContext context, String? carId) {
if (carId == null) return null;
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;
}
/// 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 carId =
carState is CarSelectComplete ? carState.selectedCar.id : '';
return BlocBuilder<TourBloc, TourState>(
builder: (context, tourState) {
// Auch ohne/vor geladener Tour den Phasen-Header zeigen — sonst
// verschwinden Kopf-Kalender, Menü und Reload hinter einer nackten
// 'Beladung'-AppBar und der Fahrer kommt an einem leeren Tag nicht
// mehr zum Datumswechsel.
final stepperHeader = PreferredSize(
preferredSize: const Size.fromHeight(140),
child: PhaseStepper(
currentPhase: DeliveryPhase.beladen,
carId: carId,
),
);
if (tourState is TourLoadFailed) {
// Fehlerseite (mit „Erneut versuchen") im selben Rahmen wie die
// übrigen Zustände — vorher ohne Scaffold/Header/Drawer.
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: stepperHeader,
body: const SafeArea(
top: false,
child: DeliveryLoadingFailedPage(),
),
);
}
if (tourState is TourEmpty) {
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: stepperHeader,
body: const SafeArea(top: false, child: _EmptyOverview()),
);
}
if (tourState is! TourLoaded) {
// Drawer auch im Ladezustand — analog zum TourEmpty-Branch,
// damit der Fahrer beim Hängen nicht ohne Logout dasitzt.
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: stepperHeader,
body: const SafeArea(
top: false,
child: Center(child: CircularProgressIndicator()),
),
);
}
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(),
appBar: stepperHeader,
body: SafeArea(
top: false,
child: ordered.isEmpty
? const _EmptyOverview()
: _OverviewList(
deliveries: ordered,
details: tourState.details,
plateResolver: (id) => _plateFor(context, id),
),
),
bottomNavigationBar: _BottomBar(carId: carId, canStart: canStart),
);
},
);
},
);
}
}
class _OverviewList extends StatelessWidget {
const _OverviewList({
required this.deliveries,
required this.details,
required this.plateResolver,
});
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) {
// 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;
// „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(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Beladereihenfolge',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'$doneActive / $totalActive Kunden',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
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(
value: totalActive == 0 ? 0.0 : doneActive / totalActive,
minHeight: 6,
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
),
],
),
),
const Divider(height: 1),
Expanded(
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,
),
],
),
),
],
);
}
}
class _OverviewTile extends StatelessWidget {
const _OverviewTile({
required this.position,
required this.delivery,
required this.standardItems,
required this.details,
required this.onTap,
});
final int position;
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 = 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);
final recipient = details.recipientOf(delivery);
Color cardColor;
Color borderColor;
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.red.withValues(alpha: 0.06);
borderColor = Colors.red.withValues(alpha: 0.35);
titleColor = Colors.red.shade700;
statusText = 'Abgebrochen';
statusIcon = Icons.cancel_outlined;
} 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 (standardDone) {
cardColor = Colors.green.withValues(alpha: 0.07);
borderColor = Colors.green.withValues(alpha: 0.35);
titleColor = Colors.green.shade700;
statusText = 'Fertig beladen';
statusIcon = Icons.check_circle_outline;
} 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';
statusIcon = Icons.pending_outlined;
} else {
cardColor = theme.colorScheme.surfaceContainerLow;
borderColor = Colors.transparent;
titleColor = theme.colorScheme.onSurface;
statusText = 'Offen';
statusIcon = Icons.radio_button_unchecked;
}
// 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 = '—';
}
// 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,
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: borderColor),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
CircleAvatar(
backgroundColor: avatarColor,
foregroundColor: theme.colorScheme.onPrimary,
radius: 18,
child: Text(
'$position',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipient.name,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: titleColor,
decoration: canceled
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
const SizedBox(height: 2),
Text(
recipient.address.oneLine,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (recipient.differsFromOrderer)
OrdererHint(
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(statusIcon,
size: 14, color: titleColor),
),
const SizedBox(width: 4),
Expanded(
child: Text(
statusText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: titleColor,
),
softWrap: true,
),
),
const SizedBox(width: 10),
Text(
progressLabel,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
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),
],
],
),
),
const Icon(Icons.chevron_right),
],
),
),
),
),
);
}
}
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});
final List<String> labels;
@override
Widget build(BuildContext context) {
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: 5),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.22),
borderRadius: BorderRadius.circular(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.amber.shade800,
),
const SizedBox(width: 4),
Flexible(
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),
],
),
),
),
],
),
);
}
}
class _EmptyOverview extends StatelessWidget {
const _EmptyOverview();
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inbox_outlined, size: 64, color: scheme.onSurfaceVariant),
const SizedBox(height: 12),
Text(
'Keine Lieferungen zum Beladen',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'Für heute ist aktuell keine Tour verfügbar.',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: scheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
// Erneut die heutige Tour vom Backend laden. `LoadTour` blendet
// währenddessen den Seiten-Ladeindikator ein (TourLoading-Zweig) und
// landet danach wieder hier (TourEmpty) oder in der Tour-Ansicht.
FilledButton.tonalIcon(
onPressed: () => context.read<TourBloc>().add(const LoadTour()),
icon: const Icon(Icons.refresh),
label: const Text('Neu laden'),
),
],
),
);
}
}