Files
Holzleitner-Lieferservice-App/lib/feature/delivery/overview/presentation/delivery_overview.dart
Dennis Nemec a9bf8ecdd1 Final commit.
2026-06-01 17:12:28 +02:00

1016 lines
35 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

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

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/article.dart';
import 'package:hl_lieferservice/domain/entity/customer.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/domain/entity/warehouse.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_info.dart';
import 'package:hl_lieferservice/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart';
/// Entscheidet beim Tap auf eine Lieferung, wohin navigiert wird:
///
/// * Aktive Lieferung mit noch offenen Filial-Artikeln → zuerst der
/// Filial-Abhol-Scan-Screen. Der Fahrer ist an der Filiale und muss die
/// Ware abscannen, bevor er ausliefern kann. Nach dem Scan kehrt er zur
/// Übersicht zurück (die Lieferung verliert ihren Filial-Hinweis).
/// * Sonst → direkt die Auslieferung (`DeliveryDetail`), die beim Kunden
/// bearbeitet wird.
///
/// Damit „schaltet" dieselbe Lieferung zustandsabhängig um, ohne dass es
/// dafür einen eigenen Status braucht.
void _openDelivery(
BuildContext context,
Delivery delivery,
TourDetails details,
) {
final needsPickup = delivery.state == DeliveryState.active &&
details.hasPendingExternalWarehouseItems(delivery);
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => needsPickup
? FilialePickupScanPage(deliveryId: delivery.id)
: DeliveryDetail(deliveryId: delivery.id),
),
);
}
/// Sektionen-Übersicht der Auslieferungs-Phase.
///
/// Architektur-Schwester von `LoadingOverviewPage`: gleiche Sektions-Logik,
/// gleicher Tile-Stil, aber andere Bucket-Semantik — wir sortieren nicht
/// nach Beladestand, sondern nach Lebenszyklus der Lieferung:
///
/// * Eine **prominente Karte** für die nächste anstehende Lieferung
/// (großer Kundenname, Adresse, Uhrzeit, Sonderwünsche). Wenn dort noch
/// Filial-Artikel offen sind, sagt sie dem Fahrer Klartext, dass er
/// zuerst das Lager ansteuern muss — inkl. Artikel-Auflistung.
/// * Sektionen darunter: `Offen`, `Pausiert`, `Fertig`, `Abgebrochen`.
///
/// `assignedCarId` wird zur Filterung herangezogen — Multi-Car-Teams sollen
/// nicht die Lieferungen der Kollegen sehen.
class DeliveryOverview extends StatefulWidget {
const DeliveryOverview({super.key, required this.details});
final TourDetails details;
@override
State<DeliveryOverview> createState() => _DeliveryOverviewState();
}
class _DeliveryOverviewState extends State<DeliveryOverview> {
String? _selectedCarId;
@override
void initState() {
super.initState();
final carSelectState = context.read<CarSelectBloc>().state;
if (carSelectState is CarSelectComplete) {
_selectedCarId = carSelectState.selectedCar.id;
} else {
// Falls keine Car-Auswahl getroffen ist (z. B. Single-Car-Team ohne
// expliziten Wechsel), das erstbeste zugewiesene Auto übernehmen.
final assigned = widget.details.deliveries
.map((d) => d.assignedCarId)
.whereType<String>()
.toList();
_selectedCarId = assigned.isNotEmpty ? assigned.first : null;
}
}
@override
Widget build(BuildContext context) {
return BlocListener<CarSelectBloc, CarSelectState>(
listener: (context, carState) {
if (carState is CarSelectComplete) {
setState(() => _selectedCarId = carState.selectedCar.id);
}
},
child: _DeliveryOverviewBody(
details: widget.details,
selectedCarId: _selectedCarId,
),
);
}
}
class _DeliveryOverviewBody extends StatelessWidget {
const _DeliveryOverviewBody({
required this.details,
required this.selectedCarId,
});
final TourDetails details;
final String? selectedCarId;
/// Sortiert nach `sortOrder` aufsteigend, weil das die vom Fahrer im
/// Sortieren-Schritt festgelegte Reihenfolge ist und identisch zur
/// Auslieferungs-Reihenfolge.
List<Delivery> _ownDeliveriesInDeliveryOrder() {
final all = details.deliveriesSorted;
if (selectedCarId == null) return all;
return all.where((d) => d.assignedCarId == selectedCarId).toList();
}
@override
Widget build(BuildContext context) {
final deliveries = _ownDeliveriesInDeliveryOrder();
// Untere System-Navigationsleiste (Home/Zurück/Recent) freihalten, damit
// das letzte Listenelement nicht dahinter rutscht. Es gibt in dieser
// Phase keinen Bottom-Bar, der den Inset abdecken würde.
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
if (deliveries.isEmpty) {
return ListView(
padding: EdgeInsets.only(bottom: bottomInset),
children: [
DeliveryInfo(details: details, selectedCarId: selectedCarId),
const SizedBox(height: 48),
const Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: Text(
'Keine Auslieferungen für dieses Fahrzeug.',
textAlign: TextAlign.center,
),
),
),
],
);
}
// Lieferungen in Buckets aufteilen. Position-Nr. bezieht sich auf die
// Auslieferungs-Reihenfolge (`sortOrder`) — bleibt sichtbar, auch wenn
// die Karte in „Pausiert" / „Fertig" einsortiert wird.
final active = <_DeliveryEntry>[];
final paused = <_DeliveryEntry>[];
final done = <_DeliveryEntry>[];
final canceled = <_DeliveryEntry>[];
for (int i = 0; i < deliveries.length; i++) {
final entry = _DeliveryEntry(position: i + 1, delivery: deliveries[i]);
switch (entry.delivery.state) {
case DeliveryState.active:
active.add(entry);
case DeliveryState.held:
paused.add(entry);
case DeliveryState.completed:
done.add(entry);
case DeliveryState.canceled:
canceled.add(entry);
}
}
// Erste aktive Lieferung ist die „Nächste" — wird aus der Offen-Liste
// herausgezogen und in eigener prominenter Karte gezeigt. Beim nächsten
// Build (nach Status-Wechsel) rutscht automatisch die nächste nach.
final _DeliveryEntry? nextUp = active.isEmpty ? null : active.removeAt(0);
// Wenn unter der „Nächste Lieferung"-Karte sonst nichts käme (typisch:
// es gibt nur diese eine Lieferung), einen dezenten Platzhalter zeigen,
// damit der Bereich nicht leer wirkt.
final nothingElseBelow = active.isEmpty &&
paused.isEmpty &&
done.isEmpty &&
canceled.isEmpty;
return ListView(
padding: EdgeInsets.only(bottom: 24 + bottomInset),
children: [
DeliveryInfo(details: details, selectedCarId: selectedCarId),
if (nextUp != null)
_NextDeliverySection(entry: nextUp, details: details),
if (nextUp != null && nothingElseBelow) const _NoFurtherDeliveriesHint(),
if (active.isNotEmpty)
_BucketSection(
title: 'Offen',
count: active.length,
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.55),
entries: active,
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,
icon: Icons.check_circle_outline,
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,
),
],
);
}
}
/// Lieferung + Position innerhalb der Auslieferungs-Reihenfolge. Position
/// überlebt das Aufsplitten in Buckets — der Fahrer sieht im Tile immer
/// „seine" Nummer, egal in welcher Sektion.
class _DeliveryEntry {
const _DeliveryEntry({required this.position, required this.delivery});
final int position;
final Delivery delivery;
}
/// Dezenter Platzhalter unter der „Nächste Lieferung"-Karte, wenn es keine
/// weiteren Lieferungen gibt (z. B. nur eine Lieferung insgesamt) — damit der
/// Bereich nicht leer wirkt. Bekommt dieselbe „Offen"-Zwischenüberschrift wie
/// die anderen Sektionen (farbiger Balken + Titel + Zähler).
class _NoFurtherDeliveriesHint extends StatelessWidget {
const _NoFurtherDeliveriesHint();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final headerColor = theme.colorScheme.primary.withValues(alpha: 0.55);
final muted = theme.colorScheme.onSurfaceVariant;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header im selben Stil wie _BucketSection (Balken + Titel + Pill).
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Row(
children: [
Container(
width: 4,
height: 18,
decoration: BoxDecoration(
color: headerColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
'Offen',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: headerColor,
letterSpacing: 0.4,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
decoration: BoxDecoration(
color: headerColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'0',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: headerColor,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.inbox_outlined, size: 18, color: muted),
const SizedBox(width: 10),
Expanded(
child: Text(
'Keine weiteren offenen Lieferungen.',
style: theme.textTheme.bodyMedium?.copyWith(color: muted),
),
),
],
),
),
),
],
);
}
}
// ─── „Nächste Lieferung" prominent ──────────────────────────────────────
/// Sektions-Header für „Nächste Lieferung" + die große Karte darunter. Der
/// Header läuft im Primary-Akzent, damit das Auge sofort dort landet.
class _NextDeliverySection extends StatelessWidget {
const _NextDeliverySection({required this.entry, required this.details});
final _DeliveryEntry entry;
final TourDetails details;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Row(
children: [
Container(
width: 4,
height: 18,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Icon(Icons.local_shipping_outlined, size: 16, color: color),
const SizedBox(width: 6),
Text(
'Nächste Lieferung',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: color,
letterSpacing: 0.4,
),
),
],
),
),
_NextDeliveryCard(entry: entry, details: details),
],
);
}
}
/// Große Highlight-Karte für die nächste anstehende Lieferung. Layout-Ziel:
/// alle „Wer / Wohin / Wann"-Infos auf einen Blick, ohne dass der Fahrer
/// in die Detail-Page muss. Pflicht-Hinweis bei offenen Filial-Items,
/// damit er nicht losfährt, ohne vorher das Lager anzusteuern.
class _NextDeliveryCard extends StatelessWidget {
const _NextDeliveryCard({required this.entry, required this.details});
final _DeliveryEntry entry;
final TourDetails details;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final delivery = entry.delivery;
final customer = details.customerOf(delivery);
final pendingExternal = details.pendingExternalWarehouseGroups(delivery);
final hasPendingExternal = pendingExternal.isNotEmpty;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
elevation: 1,
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.55),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(
color: theme.colorScheme.primary.withValues(alpha: 0.45),
width: 1.2,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => _openDelivery(context, delivery, details),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_HeaderRow(
position: entry.position,
customer: customer,
desiredTime: delivery.desiredTime,
),
const SizedBox(height: 10),
_InfoRow(
icon: Icons.location_on_outlined,
text: delivery.deliveryAddressSnapshot.oneLine,
emphasized: true,
),
if (delivery.specialAgreements != null &&
delivery.specialAgreements!.isNotEmpty) ...[
const SizedBox(height: 6),
_InfoRow(
icon: Icons.sticky_note_2_outlined,
text: delivery.specialAgreements!,
),
],
if (hasPendingExternal) ...[
const SizedBox(height: 12),
_PendingExternalBanner(
groups: pendingExternal,
articleLookup: details.articleOf,
),
],
if (delivery.prepaidAmount > 0) ...[
const SizedBox(height: 8),
_InfoRow(
icon: Icons.payments_outlined,
text:
'Anzahlung: ${delivery.prepaidAmount.toStringAsFixed(2)}',
),
],
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () => _openDelivery(context, delivery, details),
icon: Icon(
hasPendingExternal
? Icons.qr_code_scanner
: Icons.arrow_forward,
),
label: Text(
hasPendingExternal
? 'Artikel aus Filiale scannen'
: 'Details öffnen',
),
),
),
],
),
),
),
);
}
}
class _HeaderRow extends StatelessWidget {
const _HeaderRow({
required this.position,
required this.customer,
required this.desiredTime,
});
final int position;
final Customer? customer;
final String? desiredTime;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
radius: 20,
child: Text(
'$position',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
customer?.name ?? '⟨Unbekannter Kunde⟩',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (desiredTime != null && desiredTime!.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.access_time,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: 4),
Text(
desiredTime!,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.primary,
),
),
],
),
],
],
),
),
],
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({
required this.icon,
required this.text,
this.emphasized = false,
});
final IconData icon;
final String text;
final bool emphasized;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
icon,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: emphasized ? 14 : 13,
fontWeight: emphasized ? FontWeight.w600 : FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
),
],
);
}
}
/// Roter Banner direkt auf der „Nächste Lieferung"-Karte. Listet die noch
/// nicht beladenen Filial-Artikel auf, damit der Fahrer sofort sieht,
/// dass er erst ins Lager muss — *und* welche Artikel ihn dort erwarten.
/// Bewusst kein dezenter Hinweis: das ist die wichtigste Information auf
/// dem Bildschirm, sobald sie zutrifft.
class _PendingExternalBanner extends StatelessWidget {
const _PendingExternalBanner({
required this.groups,
required this.articleLookup,
});
/// Filiale + offene Items pro Lager.
final List<({Warehouse warehouse, List<DeliveryItem> items})> groups;
final Article? Function(String articleId) articleLookup;
String _itemLabel(DeliveryItem item) {
final article = articleLookup(item.articleId);
final name = article?.name ?? '⟨Unbekannter Artikel⟩';
final number = article?.articleNumber ?? '';
final qty = item.requiredQuantity;
if (number.isEmpty) return '$qty × $name';
return '$qty × $name ($number)';
}
@override
Widget build(BuildContext context) {
final color = Colors.amber.shade800;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.amber.withValues(alpha: 0.7)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warehouse_outlined, size: 18, color: color),
const SizedBox(width: 6),
Expanded(
child: Text(
'Erst Artikel aus der Filiale holen!',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: color,
),
),
),
],
),
const SizedBox(height: 8),
for (final group in groups) ...[
Text(
group.warehouse.name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: color,
letterSpacing: 0.3,
),
),
const SizedBox(height: 2),
for (final item in group.items)
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'',
style: TextStyle(color: color, fontSize: 13),
),
Expanded(
child: Text(
_itemLabel(item),
style: TextStyle(
fontSize: 13,
color: Colors.brown.shade900,
),
),
),
],
),
),
const SizedBox(height: 4),
],
],
),
);
}
}
// ─── Standard-Sektion + Tile ────────────────────────────────────────────
/// Generische Bucket-Sektion mit Farb-Akzent, Pill-Counter und Tiles.
/// Visuelle Sprache identisch zur Beladen-Übersicht, damit der Fahrer
/// keine zwei UI-Paradigmen lernen muss.
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<_DeliveryEntry> entries;
final TourDetails details;
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)
_DeliveryTile(entry: entry, details: details),
],
);
}
}
/// Kompakter Tile in den Sektionen unterhalb der „Nächste Lieferung"-
/// Karte. Zeigt Position, Kundenname, Adresse, Uhrzeit (falls vorhanden),
/// Status — und ein Filial-Badge, wenn dort noch Artikel offen sind.
class _DeliveryTile extends StatelessWidget {
const _DeliveryTile({required this.entry, required this.details});
final _DeliveryEntry entry;
final TourDetails details;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final delivery = entry.delivery;
final customer = details.customerOf(delivery);
final pendingExternal = details.pendingExternalWarehouseGroups(delivery);
final hasPendingExternal = pendingExternal.isNotEmpty;
final style = _TileStyle.forState(theme, delivery.state);
return Opacity(
opacity: delivery.state == DeliveryState.canceled ? 0.65 : 1.0,
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
elevation: 0,
color: style.cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: style.borderColor),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _openDelivery(context, delivery, details),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
CircleAvatar(
backgroundColor: style.avatarColor,
foregroundColor: theme.colorScheme.onPrimary,
radius: 18,
child: Text(
'${entry.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: style.titleColor,
decoration: delivery.state == DeliveryState.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(
style.statusIcon,
size: 14,
color: style.titleColor,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
style.statusLabel,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: style.titleColor,
),
),
),
if (delivery.desiredTime != null &&
delivery.desiredTime!.isNotEmpty) ...[
const SizedBox(width: 8),
Icon(
Icons.access_time,
size: 12,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 2),
Text(
delivery.desiredTime!,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
if (delivery.stateReason != null &&
delivery.stateReason!.isNotEmpty &&
(delivery.state == DeliveryState.held ||
delivery.state == DeliveryState.canceled))
Padding(
padding: const EdgeInsets.only(left: 18, top: 2),
child: Text(
'Grund: ${delivery.stateReason!}',
style: TextStyle(
fontSize: 11,
color: style.titleColor,
),
),
),
// Filial-Hinweis nur bei aktiven Lieferungen mit
// offenen Items — pausiert/abgebrochen sind ohnehin
// nicht in Arbeit, und „fertig" hat trivialerweise
// keine offenen Items mehr.
if (delivery.state == DeliveryState.active &&
hasPendingExternal) ...[
const SizedBox(height: 6),
_PendingExternalBadge(
warehouses: pendingExternal
.map((g) => g.warehouse.name)
.toList(growable: false),
),
],
],
),
),
const Icon(Icons.chevron_right),
],
),
),
),
),
);
}
}
/// Visuelle Sprache je nach Lebenszyklus der Lieferung. Bewusst getrennt,
/// damit der Tile-Builder eine einzige `_TileStyle.forState(...)` baut und
/// nicht in jeder Zeile wieder ein `switch` braucht.
class _TileStyle {
const _TileStyle({
required this.cardColor,
required this.borderColor,
required this.titleColor,
required this.avatarColor,
required this.statusLabel,
required this.statusIcon,
});
final Color cardColor;
final Color borderColor;
final Color titleColor;
final Color avatarColor;
final String statusLabel;
final IconData statusIcon;
factory _TileStyle.forState(ThemeData theme, DeliveryState state) {
switch (state) {
case DeliveryState.active:
return _TileStyle(
cardColor: theme.colorScheme.surfaceContainerLow,
borderColor: Colors.transparent,
titleColor: theme.colorScheme.onSurface,
avatarColor: theme.colorScheme.primary,
statusLabel: 'Offen',
statusIcon: Icons.radio_button_unchecked,
);
case DeliveryState.held:
return _TileStyle(
cardColor: Colors.orange.withValues(alpha: 0.07),
borderColor: Colors.orange.withValues(alpha: 0.45),
titleColor: Colors.orange.shade800,
avatarColor: Colors.orange.shade800,
statusLabel: 'Pausiert',
statusIcon: Icons.pause_circle_outline,
);
case DeliveryState.completed:
return _TileStyle(
cardColor: Colors.green.withValues(alpha: 0.07),
borderColor: Colors.green.withValues(alpha: 0.35),
titleColor: Colors.green.shade700,
avatarColor: Colors.green.shade700,
statusLabel: 'Abgeschlossen',
statusIcon: Icons.check_circle_outline,
);
case DeliveryState.canceled:
return _TileStyle(
cardColor: Colors.red.withValues(alpha: 0.06),
borderColor: Colors.red.withValues(alpha: 0.35),
titleColor: Colors.red.shade700,
avatarColor: Colors.red.shade700,
statusLabel: 'Abgebrochen',
statusIcon: Icons.cancel_outlined,
);
}
}
}
/// Kompaktes Filial-Badge fürs Tile (Sektionen-Liste). Anders als das
/// `_PendingExternalBanner` auf der „Nächste Lieferung"-Karte zeigt es
/// nur die Lager-Namen — die Artikel-Detail-Auflistung würde den Tile
/// optisch sprengen.
class _PendingExternalBadge extends StatelessWidget {
const _PendingExternalBadge({required this.warehouses});
final List<String> warehouses;
@override
Widget build(BuildContext context) {
final color = Colors.amber.shade800;
final text = warehouses.isEmpty
? 'Filial-Artikel offen'
: 'Erst aus Filiale holen: ${warehouses.join(", ")}';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
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: color),
const SizedBox(width: 4),
Flexible(
child: Text(
text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}