1016 lines
35 KiB
Dart
1016 lines
35 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:hl_lieferservice/domain/entity/article.dart';
|
||
import 'package:hl_lieferservice/domain/entity/customer.dart';
|
||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
|
||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||
import 'package:hl_lieferservice/domain/entity/warehouse.dart';
|
||
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
|
||
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
|
||
import 'package:hl_lieferservice/feature/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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|