- PhaseStepper: Reload-Button (RefreshTour, Spinner waehrend Refresh) - Beladen-Empty-State: 'Neu laden'-Button (LoadTour) + Hinweis 'keine Tour verfuegbar' - Drawer + AppBar in TourEmpty/Lade-Branches (Beladen-Uebersicht, Lieferungen auswaehlen, Sortieren) -> kein Festsitzen ohne Logout Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
897 lines
33 KiB
Dart
897 lines
33 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';
|
|
|
|
/// Ü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) {
|
|
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) {
|
|
// Drawer auch im Ladezustand — analog zum TourEmpty-Branch,
|
|
// damit der Fahrer beim Hängen nicht ohne Logout dasitzt.
|
|
return Scaffold(
|
|
drawer: const HomeAppDrawer(),
|
|
appBar: AppBar(title: const Text('Beladung')),
|
|
body: const 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: PreferredSize(
|
|
preferredSize: const Size.fromHeight(140),
|
|
child: PhaseStepper(
|
|
currentPhase: DeliveryPhase.beladen,
|
|
carId: carId,
|
|
),
|
|
),
|
|
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);
|
|
|
|
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(
|
|
customer?.name ?? '⟨Unbekannter Kunde⟩',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
color: titleColor,
|
|
decoration: canceled
|
|
? TextDecoration.lineThrough
|
|
: TextDecoration.none,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
delivery.deliveryAddressSnapshot.oneLine,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
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'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|