Final commit.
This commit is contained in:
@ -1,115 +0,0 @@
|
||||
import 'package:hl_lieferservice/model/article.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
/// Aggregiert eine [Delivery] mit ihren scannbaren Artikeln zu einer
|
||||
/// Belade-Einheit. Bildet die Datenbasis für die Beladen-Phase (Vollbild-
|
||||
/// Kunde + Übersicht).
|
||||
///
|
||||
/// **Wichtige Geschäftslogik:** Für die Frage "ist diese Lieferung fertig
|
||||
/// beladen?" zählen nur Artikel aus dem **Standardlager** (warehouseNr
|
||||
/// `null` oder `"0"`). Außenlager-Artikel werden separat beim
|
||||
/// Kundenbesuch in der Ausliefer-Phase abgeholt — sie blockieren also
|
||||
/// nicht den Beladen-Abschluss. Konsequenz: alle Counter-Getter
|
||||
/// ([totalArticles], [completeArticles], [scannedUnits], [totalUnits],
|
||||
/// [isComplete], [isPartial], [hasAnyScanned]) ignorieren Außenlager-
|
||||
/// Artikel. Wer alle Artikel braucht, greift direkt auf [articles] zu.
|
||||
///
|
||||
/// Aufrufer füllen [articles] mit den scannbaren Artikeln dieser Lieferung
|
||||
/// (also nicht alle Artikel der Lieferung — vorgefiltert über
|
||||
/// `Article.scannable`).
|
||||
class LoadingGroup {
|
||||
/// Die zugrundeliegende Lieferung (inkl. Kunde, Adresse, State).
|
||||
final Delivery delivery;
|
||||
|
||||
/// Nummernschild des zugewiesenen Fahrzeugs zur Darstellung im Badge.
|
||||
/// `null` wenn die Lieferung noch keinem Auto zugeordnet ist.
|
||||
final String? carPlate;
|
||||
|
||||
/// Die scannbaren Artikel der Lieferung (bereits vorgefiltert).
|
||||
final List<Article> articles;
|
||||
|
||||
const LoadingGroup({
|
||||
required this.delivery,
|
||||
required this.articles,
|
||||
this.carPlate,
|
||||
});
|
||||
|
||||
/// Alle Standardlager-Artikel (Lager-Nummer `null` oder `"0"`). Bildet
|
||||
/// die Basis aller Beladen-Counter, weil Außenlager-Ware nicht in der
|
||||
/// Belade-Halle scannbar ist.
|
||||
List<Article> get _standardArticles => articles
|
||||
.where((a) => !_isExternalWarehouse(a.warehouseNr))
|
||||
.toList(growable: false);
|
||||
|
||||
/// Anzahl der scannbaren Standardlager-Artikel. Parent-Artikel zählen
|
||||
/// als 1 (nicht je Komponente).
|
||||
int get totalArticles => _standardArticles.length;
|
||||
|
||||
/// Anzahl der vollständig gescannten Standardlager-Artikel. Bei Parent-
|
||||
/// Artikeln gilt "vollständig" = alle Komponenten vollständig.
|
||||
int get completeArticles =>
|
||||
_standardArticles.where((a) => a.isFullyScanned).length;
|
||||
|
||||
/// Gesamtanzahl der erwarteten Einzelstücke aus dem Standardlager —
|
||||
/// bei Parent-Artikeln summiert über die Required-Amounts der
|
||||
/// Komponenten.
|
||||
int get totalUnits => _standardArticles.fold(0, (sum, a) {
|
||||
if (a.isParent && a.components.isNotEmpty) {
|
||||
return sum + a.components.fold(0, (s, c) => s + c.requiredAmount);
|
||||
}
|
||||
return sum + a.amount;
|
||||
});
|
||||
|
||||
/// Bereits gescannte Einzelstücke aus dem Standardlager — analog zu
|
||||
/// [totalUnits].
|
||||
int get scannedUnits => _standardArticles.fold(0, (sum, a) {
|
||||
if (a.isParent && a.components.isNotEmpty) {
|
||||
return sum + a.components.fold(0, (s, c) => s + c.scannedAmount);
|
||||
}
|
||||
return sum + a.scannedAmount + a.scannedRemovedAmount;
|
||||
});
|
||||
|
||||
/// `true`, wenn alle Standardlager-Artikel vollständig gescannt sind.
|
||||
///
|
||||
/// Edge-Case: Lieferung **ohne** Standardlager-Artikel (alle Artikel
|
||||
/// liegen in Außenlagern) → automatisch fertig, weil in der Beladen-
|
||||
/// Phase nichts zu tun ist.
|
||||
bool get isComplete {
|
||||
if (articles.isEmpty) return false;
|
||||
if (_standardArticles.isEmpty) return true;
|
||||
return completeArticles == totalArticles;
|
||||
}
|
||||
|
||||
/// `true`, wenn mindestens ein Stück gescannt wurde — egal ob Artikel
|
||||
/// vollständig oder nicht.
|
||||
bool get hasAnyScanned => scannedUnits > 0;
|
||||
|
||||
/// `true`, wenn die Lieferung angefangen, aber nicht abgeschlossen wurde.
|
||||
bool get isPartial => hasAnyScanned && !isComplete;
|
||||
|
||||
/// `true`, wenn mindestens ein Artikel der Lieferung NICHT aus dem
|
||||
/// Standard-Lager kommt. Standard-Lager hat die Nummer "0"; ein
|
||||
/// `warehouseNr == null` interpretieren wir als "nicht angegeben" und
|
||||
/// damit als Standard (kein False-Positive auf Datenlücken).
|
||||
bool get hasExternalWarehouseArticles =>
|
||||
articles.any((a) => _isExternalWarehouse(a.warehouseNr));
|
||||
|
||||
/// Eindeutige Liste der Außenlager-Namen, die in dieser Lieferung
|
||||
/// vorkommen — für Badges/Hinweise in der Übersicht. Wenn ein Artikel
|
||||
/// nur eine `warehouseNr` aber keinen Namen hat, wird die Nummer als
|
||||
/// Fallback genommen.
|
||||
List<String> get externalWarehouseLabels {
|
||||
final labels = <String>{};
|
||||
for (final a in articles) {
|
||||
if (!_isExternalWarehouse(a.warehouseNr)) continue;
|
||||
final label = (a.warehouseName?.isNotEmpty ?? false)
|
||||
? a.warehouseName!
|
||||
: "Lager ${a.warehouseNr}";
|
||||
labels.add(label);
|
||||
}
|
||||
return labels.toList(growable: false);
|
||||
}
|
||||
|
||||
static bool _isExternalWarehouse(String? nr) =>
|
||||
nr != null && nr.isNotEmpty && nr != "0";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,75 +1,100 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
|
||||
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart';
|
||||
import 'package:hl_lieferservice/feature/loading/model/loading_group.dart';
|
||||
import 'package:hl_lieferservice/feature/loading/presentation/loading_customer_page.dart';
|
||||
import 'package:hl_lieferservice/feature/loading/util/loading_order.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
import 'package:hl_lieferservice/model/tour.dart';
|
||||
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
|
||||
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
|
||||
|
||||
/// Übersichts-Ansicht für die Beladen-Phase: alle Kunden in Beladereihen-
|
||||
/// folge mit Fortschritts-Status. KEIN Scanner — der Scanner-Fokus bleibt
|
||||
/// auf der [LoadingCustomerPage]. Tap auf einen Kunden öffnet seine
|
||||
/// Vollbild-Ansicht mit dem entsprechenden Index.
|
||||
/// Übersichts-Ansicht für die Beladen-Phase: alle Kunden mit ihren
|
||||
/// Artikeln und Soll/Ist-Mengen.
|
||||
///
|
||||
/// Scans werden in der `LoadingCustomerPage` (Vollbild pro Kunde)
|
||||
/// ausgelöst; diese Übersicht ist reines Status-Display und Navigation.
|
||||
/// Der Button „Auslieferungs-Phase starten" unten erlaubt dem Fahrer,
|
||||
/// jederzeit in die nächste Phase zu wechseln — die App erzwingt
|
||||
/// bewusst keine 100%-Auslieferung (z. B. wenn ein Artikel fehlt und
|
||||
/// der Fahrer das später hold/cancel-en will).
|
||||
class LoadingOverviewPage extends StatelessWidget {
|
||||
const LoadingOverviewPage({super.key});
|
||||
|
||||
String? _lookupCarPlate(String? carId, Tour tour) {
|
||||
String? _plateFor(BuildContext context, String? carId) {
|
||||
if (carId == null) return null;
|
||||
return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate;
|
||||
final state = context.read<CarsBloc>().state;
|
||||
if (state is! CarsLoaded) return null;
|
||||
for (final c in state.cars) {
|
||||
if (c.id == carId) return c.plate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<LoadingGroup> _buildGroups(TourLoaded state, String carIdStr) {
|
||||
final orderedIds = LoadingOrder.computeForCar(
|
||||
state: state,
|
||||
carIdStr: carIdStr,
|
||||
);
|
||||
final byId = {for (final d in state.tour.deliveries) d.id: d};
|
||||
final groups = <LoadingGroup>[];
|
||||
for (final id in orderedIds) {
|
||||
final delivery = byId[id];
|
||||
if (delivery == null) continue;
|
||||
if (delivery.state == DeliveryState.finished) continue;
|
||||
final scannable =
|
||||
delivery.articles.where((a) => a.scannable).toList(growable: false);
|
||||
if (scannable.isEmpty && delivery.state != DeliveryState.canceled) {
|
||||
continue;
|
||||
}
|
||||
groups.add(LoadingGroup(
|
||||
delivery: delivery,
|
||||
articles: scannable,
|
||||
carPlate: _lookupCarPlate(delivery.carId, state.tour),
|
||||
));
|
||||
}
|
||||
return groups;
|
||||
/// Beladereihenfolge = umgekehrte Sortier-Reihenfolge: wer zuletzt
|
||||
/// ausgeliefert wird, wird zuerst beladen (kommt unten in den LKW).
|
||||
List<Delivery> _ownDeliveriesInLoadingOrder(
|
||||
TourDetails details,
|
||||
String carId,
|
||||
bool multiCarTeam,
|
||||
) {
|
||||
final relevant = multiCarTeam
|
||||
? details.deliveriesSorted
|
||||
.where((d) => d.assignedCarId == carId)
|
||||
.toList()
|
||||
: details.deliveriesSorted;
|
||||
return relevant.reversed.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CarSelectBloc, CarSelectState>(
|
||||
builder: (context, carState) {
|
||||
final carIdStr =
|
||||
carState is CarSelectComplete ? carState.selectedCar.id.toString() : "";
|
||||
final carId =
|
||||
carState is CarSelectComplete ? carState.selectedCar.id : '';
|
||||
|
||||
return BlocBuilder<TourBloc, TourState>(
|
||||
builder: (context, tourState) {
|
||||
if (tourState is TourLoadingFailed) {
|
||||
if (tourState is TourLoadFailed) {
|
||||
return const DeliveryLoadingFailedPage();
|
||||
}
|
||||
if (tourState is TourEmpty) {
|
||||
return Scaffold(
|
||||
drawer: const HomeAppDrawer(),
|
||||
appBar: AppBar(title: const Text('Beladung')),
|
||||
body: const _EmptyOverview(),
|
||||
);
|
||||
}
|
||||
if (tourState is! TourLoaded) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final groups = _buildGroups(tourState, carIdStr);
|
||||
|
||||
final carsState = context.read<CarsBloc>().state;
|
||||
final multiCarTeam =
|
||||
carsState is CarsLoaded && carsState.cars.length >= 2;
|
||||
final ordered = _ownDeliveriesInLoadingOrder(
|
||||
tourState.details,
|
||||
carId,
|
||||
multiCarTeam,
|
||||
);
|
||||
|
||||
// Gate für „Auslieferungs-Phase starten": jede aktive Lieferung
|
||||
// muss im Standardlager fertig beladen sein. Pausierte/abgebrochene
|
||||
// zählen nicht; offene Filial-Items dürfen bleiben (werden unterwegs
|
||||
// geholt — siehe Status-Logik). Keine aktive Lieferung ⇒ kein Block.
|
||||
final canStart = ordered
|
||||
.where((d) => d.state == DeliveryState.active)
|
||||
.every(tourState.details.standardWarehouseLoadingDone);
|
||||
|
||||
return Scaffold(
|
||||
drawer: const HomeAppDrawer(),
|
||||
@ -77,15 +102,20 @@ class LoadingOverviewPage extends StatelessWidget {
|
||||
preferredSize: const Size.fromHeight(140),
|
||||
child: PhaseStepper(
|
||||
currentPhase: DeliveryPhase.beladen,
|
||||
carId: carIdStr,
|
||||
carId: carId,
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: groups.isEmpty
|
||||
child: ordered.isEmpty
|
||||
? const _EmptyOverview()
|
||||
: _OverviewList(groups: groups),
|
||||
: _OverviewList(
|
||||
deliveries: ordered,
|
||||
details: tourState.details,
|
||||
plateResolver: (id) => _plateFor(context, id),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: _BottomBar(carId: carId, canStart: canStart),
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -95,18 +125,104 @@ class LoadingOverviewPage extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _OverviewList extends StatelessWidget {
|
||||
const _OverviewList({required this.groups});
|
||||
const _OverviewList({
|
||||
required this.deliveries,
|
||||
required this.details,
|
||||
required this.plateResolver,
|
||||
});
|
||||
|
||||
final List<LoadingGroup> groups;
|
||||
final List<Delivery> deliveries;
|
||||
final TourDetails details;
|
||||
final String? Function(String?) plateResolver;
|
||||
|
||||
/// Klassifiziert eine Lieferung in einen UX-Bucket. Bucket bestimmt,
|
||||
/// in welcher Sektion das Tile in der Übersicht landet.
|
||||
///
|
||||
/// Lifecycle-Zustände (`held` / `canceled`) gewinnen vor der
|
||||
/// Standardlager-/Filial-Logik: eine pausierte oder abgebrochene
|
||||
/// Lieferung soll nicht versehentlich als „nächste Lieferung" oder
|
||||
/// „offen" auftauchen, auch wenn ihr Standardlager-Counter Items
|
||||
/// hätte.
|
||||
_OverviewBucket _bucketOf(Delivery delivery) {
|
||||
if (delivery.state == DeliveryState.canceled) {
|
||||
return _OverviewBucket.canceled;
|
||||
}
|
||||
if (delivery.state == DeliveryState.held) {
|
||||
return _OverviewBucket.paused;
|
||||
}
|
||||
final standardDone = details.standardWarehouseLoadingDone(delivery);
|
||||
final hasExternal = details.hasExternalWarehouseItems(delivery);
|
||||
if (standardDone && hasExternal) return _OverviewBucket.later;
|
||||
if (standardDone) return _OverviewBucket.done;
|
||||
return _OverviewBucket.open;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalActive = groups
|
||||
.where((g) => g.delivery.state != DeliveryState.canceled)
|
||||
// Standardlager-Items pro Lieferung — Basis für den Fortschritts-
|
||||
// Counter („X / Y Artikel" am Kunden). Filial-Items zählen hier
|
||||
// nicht; sie werden separat geladen und sollen den Fortschritt im
|
||||
// Hauptlager nicht verwässern.
|
||||
final standardItemsPerDelivery = <String, List<DeliveryItem>>{};
|
||||
for (final d in deliveries) {
|
||||
standardItemsPerDelivery[d.id] = d.items.where((it) {
|
||||
if (it.isRemoved) return false;
|
||||
if (!details.isArticleScannable(it.articleId)) return false;
|
||||
final w = details.warehouseOf(it.warehouseId);
|
||||
return w?.isStandard ?? false;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Lieferungen in drei Buckets sortieren. Innerhalb der Buckets
|
||||
// bleibt die Belade-Reihenfolge erhalten (Original-Index), damit der
|
||||
// Fahrer „Kunde Nr. 3 in der Reihenfolge" jederzeit identifizieren
|
||||
// kann — auch wenn die Karte gerade in „Später abholen" einsortiert
|
||||
// ist.
|
||||
final open = <_OverviewEntry>[];
|
||||
final later = <_OverviewEntry>[];
|
||||
final paused = <_OverviewEntry>[];
|
||||
final done = <_OverviewEntry>[];
|
||||
final canceled = <_OverviewEntry>[];
|
||||
for (int i = 0; i < deliveries.length; i++) {
|
||||
final d = deliveries[i];
|
||||
final entry = _OverviewEntry(
|
||||
originalIndex: i,
|
||||
delivery: d,
|
||||
standardItems: standardItemsPerDelivery[d.id] ?? const [],
|
||||
);
|
||||
switch (_bucketOf(d)) {
|
||||
case _OverviewBucket.open:
|
||||
open.add(entry);
|
||||
case _OverviewBucket.later:
|
||||
later.add(entry);
|
||||
case _OverviewBucket.paused:
|
||||
paused.add(entry);
|
||||
case _OverviewBucket.done:
|
||||
done.add(entry);
|
||||
case _OverviewBucket.canceled:
|
||||
canceled.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// „Nächste Lieferung" zieht den ersten Offen-Eintrag heraus und
|
||||
// präsentiert ihn in einer eigenen Sektion ganz oben — der Fahrer
|
||||
// sieht damit immer sofort, was als nächstes anliegt, statt aus
|
||||
// einer Liste die Position-Nr. 1 selbst rauszusuchen. Sobald die
|
||||
// Lieferung beladen ist, rutscht sie in „Später abholen" oder
|
||||
// „Fertig" und der Eintrag auf Position 2 wird zur neuen
|
||||
// „Nächsten" — automatisch, da diese Logik bei jedem Build neu
|
||||
// läuft.
|
||||
final _OverviewEntry? nextUp = open.isEmpty ? null : open.removeAt(0);
|
||||
|
||||
final totalActive = deliveries
|
||||
.where((d) => d.state != DeliveryState.canceled)
|
||||
.length;
|
||||
final doneActive = groups
|
||||
.where((g) =>
|
||||
g.delivery.state != DeliveryState.canceled && g.isComplete)
|
||||
// „Fertig beladen" = Standardlager fertig. Filial-Items
|
||||
// blockieren den Übergang in die Auslieferungs-Phase nicht.
|
||||
final doneActive = deliveries
|
||||
.where((d) =>
|
||||
d.state != DeliveryState.canceled &&
|
||||
details.standardWarehouseLoadingDone(d))
|
||||
.length;
|
||||
|
||||
return Column(
|
||||
@ -120,18 +236,42 @@ class _OverviewList extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"Beladereihenfolge",
|
||||
'Beladereihenfolge',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
"$doneActive / $totalActive Kunden",
|
||||
'$doneActive / $totalActive Kunden',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: 4),
|
||||
// Macht den Bezugsrahmen der Phase explizit: Es wird im
|
||||
// Standardlager beladen. Filial-Artikel sind die Ausnahme
|
||||
// und werden separat geholt — sonst entsteht der Eindruck,
|
||||
// man müsse für jede Lieferung mehrere Lager ansteuern.
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Beladung im Standardlager · Filial-Artikel werden separat geholt',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
@ -140,11 +280,6 @@ class _OverviewList extends StatelessWidget {
|
||||
backgroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
doneActive == totalActive && totalActive > 0
|
||||
? Colors.green
|
||||
: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -152,27 +287,64 @@ class _OverviewList extends StatelessWidget {
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 16),
|
||||
itemCount: groups.length,
|
||||
itemBuilder: (context, index) {
|
||||
final g = groups[index];
|
||||
return _OverviewTile(
|
||||
position: index + 1,
|
||||
group: g,
|
||||
onTap: () {
|
||||
// Push (kein pushReplacement): die Übersicht ist seit dem
|
||||
// Routing-Umbau in home.dart die Wurzel der Beladen-Phase.
|
||||
// Vom Vollbild kehrt der Fahrer per pop zurück auf diese
|
||||
// Übersicht — der Stack bleibt damit flach.
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LoadingCustomerPage(initialIndex: index),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 16),
|
||||
children: [
|
||||
if (nextUp != null)
|
||||
_BucketSection(
|
||||
title: 'Nächste Lieferung',
|
||||
count: 1,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
icon: Icons.local_shipping_outlined,
|
||||
entries: [nextUp],
|
||||
details: details,
|
||||
),
|
||||
if (open.isNotEmpty)
|
||||
_BucketSection(
|
||||
title: 'Offen',
|
||||
count: open.length,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.55),
|
||||
entries: open,
|
||||
details: details,
|
||||
),
|
||||
if (later.isNotEmpty)
|
||||
_BucketSection(
|
||||
title: 'Später abholen',
|
||||
count: later.length,
|
||||
color: Colors.amber.shade800,
|
||||
entries: later,
|
||||
details: details,
|
||||
),
|
||||
if (paused.isNotEmpty)
|
||||
_BucketSection(
|
||||
title: 'Pausiert',
|
||||
count: paused.length,
|
||||
color: Colors.orange.shade800,
|
||||
icon: Icons.pause_circle_outline,
|
||||
entries: paused,
|
||||
details: details,
|
||||
),
|
||||
if (done.isNotEmpty)
|
||||
_BucketSection(
|
||||
title: 'Fertig',
|
||||
count: done.length,
|
||||
color: Colors.green.shade700,
|
||||
entries: done,
|
||||
details: details,
|
||||
),
|
||||
if (canceled.isNotEmpty)
|
||||
_BucketSection(
|
||||
title: 'Abgebrochen',
|
||||
count: canceled.length,
|
||||
color: Colors.red.shade700,
|
||||
icon: Icons.cancel_outlined,
|
||||
entries: canceled,
|
||||
details: details,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -183,81 +355,138 @@ class _OverviewList extends StatelessWidget {
|
||||
class _OverviewTile extends StatelessWidget {
|
||||
const _OverviewTile({
|
||||
required this.position,
|
||||
required this.group,
|
||||
required this.delivery,
|
||||
required this.standardItems,
|
||||
required this.details,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final int position;
|
||||
final LoadingGroup group;
|
||||
final Delivery delivery;
|
||||
|
||||
/// Scanbare, nicht-entfernte Items aus dem Standardlager. Basis für
|
||||
/// den Beladen-Fortschritt — Filial-Items werden separat geladen
|
||||
/// und zählen hier nicht mit.
|
||||
final List<DeliveryItem> standardItems;
|
||||
final TourDetails details;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final canceled = group.delivery.state == DeliveryState.canceled;
|
||||
final isComplete = group.isComplete;
|
||||
final isPartial = group.isPartial;
|
||||
final hasExternalWarehouse = group.hasExternalWarehouseArticles;
|
||||
final canceled = delivery.state == DeliveryState.canceled;
|
||||
final held = delivery.state == DeliveryState.held;
|
||||
final standardDone = details.standardWarehouseLoadingDone(delivery);
|
||||
final hasExternal = details.hasExternalWarehouseItems(delivery);
|
||||
final externalLabels = details.externalWarehouseLabels(delivery);
|
||||
final scannedStandardCount =
|
||||
standardItems.where((it) => it.isDone).length;
|
||||
final scannedAnyStandard =
|
||||
standardItems.any((it) => it.scanProgress.scannedQuantity > 0);
|
||||
final customer = details.customerOf(delivery);
|
||||
|
||||
// cardColor und borderColor sind nicht final, weil das Außenlager-
|
||||
// Highlight sie weiter unten ggf. überschreibt.
|
||||
Color cardColor;
|
||||
Color borderColor;
|
||||
final Color titleColor;
|
||||
final String statusText;
|
||||
final IconData statusIcon;
|
||||
Color titleColor;
|
||||
String statusText;
|
||||
IconData statusIcon;
|
||||
|
||||
// Lifecycle-States gewinnen vor der Beladen-Logik: pausiert /
|
||||
// abgebrochen sollen visuell sofort als solche erkennbar sein —
|
||||
// egal wie weit der Standardlager-Counter war.
|
||||
if (canceled) {
|
||||
cardColor = Colors.grey.withValues(alpha: 0.08);
|
||||
borderColor = Colors.grey.withValues(alpha: 0.35);
|
||||
titleColor = Colors.grey.shade700;
|
||||
statusText = "Abgebrochen";
|
||||
cardColor = Colors.red.withValues(alpha: 0.06);
|
||||
borderColor = Colors.red.withValues(alpha: 0.35);
|
||||
titleColor = Colors.red.shade700;
|
||||
statusText = 'Abgebrochen';
|
||||
statusIcon = Icons.cancel_outlined;
|
||||
} else if (isComplete && hasExternalWarehouse) {
|
||||
// Standardlager ist fertig, aber es liegen noch Artikel in einem
|
||||
// anderen Lager — die Lieferung ist also NICHT komplett beladen.
|
||||
// Wir machen das im Status-Text explizit, damit der Fahrer nicht
|
||||
// fälschlich davon ausgeht, dass nichts mehr offen ist.
|
||||
cardColor = Colors.deepOrange.withValues(alpha: 0.10);
|
||||
borderColor = Colors.deepOrange.withValues(alpha: 0.45);
|
||||
titleColor = Colors.deepOrange.shade800;
|
||||
statusText = "Standardlager fertig — Außenlager offen";
|
||||
} else if (held) {
|
||||
cardColor = Colors.orange.withValues(alpha: 0.07);
|
||||
borderColor = Colors.orange.withValues(alpha: 0.45);
|
||||
titleColor = Colors.orange.shade800;
|
||||
statusText = 'Pausiert';
|
||||
statusIcon = Icons.pause_circle_outline;
|
||||
} else if (standardDone && hasExternal) {
|
||||
// Sonderfall, fachlich wichtig: Standardlager ist durch, aber im
|
||||
// Filiale wartet noch was. Der Fahrer kann technisch in die
|
||||
// Auslieferungs-Phase, muss aber wissen, dass er zwischendurch
|
||||
// ein zweites Lager ansteuert.
|
||||
cardColor = Colors.amber.withValues(alpha: 0.18);
|
||||
borderColor = Colors.amber.withValues(alpha: 0.55);
|
||||
titleColor = Colors.amber.shade800;
|
||||
statusText = 'Standardlager fertig — Filiale offen';
|
||||
statusIcon = Icons.warehouse_outlined;
|
||||
} else if (isComplete) {
|
||||
} else if (standardDone) {
|
||||
cardColor = Colors.green.withValues(alpha: 0.07);
|
||||
borderColor = Colors.green.withValues(alpha: 0.35);
|
||||
titleColor = Colors.green.shade700;
|
||||
statusText = "Fertig beladen";
|
||||
statusText = 'Fertig beladen';
|
||||
statusIcon = Icons.check_circle_outline;
|
||||
} else if (isPartial) {
|
||||
} else if (scannedAnyStandard) {
|
||||
cardColor = Colors.orange.withValues(alpha: 0.07);
|
||||
borderColor = Colors.orange.withValues(alpha: 0.35);
|
||||
titleColor = Colors.orange.shade800;
|
||||
statusText = "Beladung läuft";
|
||||
statusText = 'Beladung läuft';
|
||||
statusIcon = Icons.pending_outlined;
|
||||
} else {
|
||||
cardColor = theme.colorScheme.surfaceContainerLow;
|
||||
borderColor = Colors.transparent;
|
||||
titleColor = theme.colorScheme.onSurface;
|
||||
statusText = "Offen";
|
||||
statusText = 'Offen';
|
||||
statusIcon = Icons.radio_button_unchecked;
|
||||
}
|
||||
|
||||
// Außenlager-Hervorhebung: lebt unabhängig vom Scan-Status. Eine
|
||||
// abgebrochene Lieferung bleibt grau, ansonsten überschreibt das
|
||||
// Außenlager-Highlight die Standard-Farben durch ein klar erkennbares
|
||||
// Orange — der Fahrer muss früh genug wissen, dass er ein anderes
|
||||
// Lager anfahren wird. Der Sonderzweig "isComplete && hasExternal-
|
||||
// Warehouse" oben hat das Highlight schon gesetzt, hier greift es
|
||||
// für die noch nicht fertigen Fälle.
|
||||
if (!canceled && hasExternalWarehouse && !isComplete) {
|
||||
cardColor = Colors.deepOrange.withValues(alpha: 0.10);
|
||||
borderColor = Colors.deepOrange.withValues(alpha: 0.65);
|
||||
// Bewusst KEIN Filial-Card-Highlight, solange das Standardlager
|
||||
// noch offen ist: eine Lieferung mit Standard- UND Filial-Items
|
||||
// soll sich nicht von einer reinen Standardlager-Lieferung
|
||||
// unterscheiden — der Fahrer belädt zuerst das Standardlager, und eine
|
||||
// orange Karte würde fälschlich einen Sonderzustand suggerieren
|
||||
// (irreführend besonders in der „Nächste Lieferung"-Sektion). Den
|
||||
// Hinweis aufs Filiale trägt allein das `_ExternalWarehouseBadge`
|
||||
// weiter unten. Der echte Sonderfall „Standardlager fertig — Filiale
|
||||
// offen" wird oben in der Status-Kette eigenständig amber gefärbt.
|
||||
|
||||
// Fortschritts-Label rechts in der Status-Zeile.
|
||||
//
|
||||
// „X / Y Artikel" bezieht sich bewusst nur auf das Standardlager (siehe
|
||||
// `standardItems`). Hat eine Lieferung dort GAR KEINE Position, ergäbe das
|
||||
// ein irreführendes „0 / 0 Artikel" — die Card sähe leer aus, obwohl die
|
||||
// Ware nur aus einer Filiale kommt (später abholen) oder es sich um eine
|
||||
// reine Dienstleistung handelt. In diesen Fällen zeigen wir statt des
|
||||
// Zählers einen sprechenden Hinweis. So wirkt keine Card mehr „verloren".
|
||||
final String progressLabel;
|
||||
if (canceled || held) {
|
||||
progressLabel = '—';
|
||||
} else if (standardItems.isNotEmpty) {
|
||||
progressLabel = '$scannedStandardCount / ${standardItems.length} Artikel';
|
||||
} else if (hasExternal) {
|
||||
// Keine Standardlager-Ware, aber Filial-Items → wird separat geholt.
|
||||
// Das Filial-Badge unten trägt die Details; hier nur der Kurz-Hinweis.
|
||||
progressLabel = 'Nur Filiale';
|
||||
} else if (details.hasServiceItems(delivery)) {
|
||||
// Weder Standardlager- noch Filial-Ware, aber eine nicht-scanbare
|
||||
// Position → reine Dienstleistung. Rechtfertigt trotzdem die Anfahrt.
|
||||
progressLabel = 'Nur Dienstleistung';
|
||||
} else {
|
||||
// Defensive: keinerlei relevante Positionen — sollte praktisch nicht
|
||||
// vorkommen. Kein „0 / 0", sondern neutraler Strich.
|
||||
progressLabel = '—';
|
||||
}
|
||||
|
||||
final progressLabel = canceled
|
||||
? "—"
|
||||
: "${group.completeArticles}/${group.totalArticles} Artikel";
|
||||
// Avatar-Hintergrund spiegelt den Lifecycle-State wider, damit die
|
||||
// Nummer-Bubble nicht weiterhin primary leuchtet, obwohl die
|
||||
// Lieferung pausiert/abgebrochen ist.
|
||||
final Color avatarColor;
|
||||
if (canceled) {
|
||||
avatarColor = Colors.red.shade700;
|
||||
} else if (held) {
|
||||
avatarColor = Colors.orange.shade800;
|
||||
} else {
|
||||
avatarColor = theme.colorScheme.primary;
|
||||
}
|
||||
|
||||
final reason = delivery.stateReason;
|
||||
final showReason = (canceled || held) && reason != null && reason.isNotEmpty;
|
||||
|
||||
return Opacity(
|
||||
opacity: canceled ? 0.65 : 1.0,
|
||||
@ -277,12 +506,11 @@ class _OverviewTile extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor:
|
||||
canceled ? Colors.grey : theme.colorScheme.primary,
|
||||
backgroundColor: avatarColor,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
radius: 18,
|
||||
child: Text(
|
||||
"$position",
|
||||
'$position',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
@ -292,7 +520,7 @@ class _OverviewTile extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
group.delivery.customer.name,
|
||||
customer?.name ?? '⟨Unbekannter Kunde⟩',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
@ -304,7 +532,7 @@ class _OverviewTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
group.delivery.customer.address.toString(),
|
||||
delivery.deliveryAddressSnapshot.oneLine,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
@ -320,9 +548,6 @@ class _OverviewTile extends StatelessWidget {
|
||||
size: 14, color: titleColor),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Expanded, damit lange Status-Texte wie
|
||||
// "Standardlager fertig — Außenlager offen"
|
||||
// umbrechen statt zu überlaufen.
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusText,
|
||||
@ -344,11 +569,25 @@ class _OverviewTile extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!canceled && hasExternalWarehouse) ...[
|
||||
const SizedBox(height: 6),
|
||||
_ExternalWarehouseBadge(
|
||||
labels: group.externalWarehouseLabels,
|
||||
if (showReason)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 18, top: 2),
|
||||
child: Text(
|
||||
'Grund: $reason',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Filial-Badge nur sichtbar, wenn die Lieferung
|
||||
// im aktiven Workflow ist. Pausiert / abgebrochen
|
||||
// soll keine zusätzliche Filial-Aufmerksamkeit
|
||||
// ziehen — die Aktion ist gerade unterbrochen oder
|
||||
// beendet.
|
||||
if (!canceled && !held && hasExternal) ...[
|
||||
const SizedBox(height: 6),
|
||||
_ExternalWarehouseBadge(labels: externalLabels),
|
||||
],
|
||||
],
|
||||
),
|
||||
@ -363,9 +602,201 @@ class _OverviewTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hinweis-Badge unter dem Status-Row einer Lieferung mit Artikeln aus
|
||||
/// einem oder mehreren Außenlagern. Listet die betroffenen Lager-Namen
|
||||
/// auf, damit der Fahrer beim Beladen weiß, wohin er zusätzlich muss.
|
||||
class _BottomBar extends StatelessWidget {
|
||||
const _BottomBar({required this.carId, required this.canStart});
|
||||
final String carId;
|
||||
|
||||
/// Nur wenn alle aktiven Lieferungen im Standardlager fertig beladen sind,
|
||||
/// darf der Fahrer in die Auslieferungs-Phase wechseln.
|
||||
final bool canStart;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (carId.isEmpty) return const SizedBox.shrink();
|
||||
final theme = Theme.of(context);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!canStart)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Erst alle Artikel des Standardlagers beladen',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: canStart
|
||||
? () {
|
||||
context.read<PhaseBloc>().add(
|
||||
PhaseSet(
|
||||
carId: carId,
|
||||
phase: DeliveryPhase.ausliefern,
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: const Text('Auslieferungs-Phase starten'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// UX-Bucket der Übersicht. Bestimmt, in welche Sektion eine Lieferung
|
||||
/// einsortiert wird:
|
||||
/// * `open` — Standardlager nicht fertig (= jetzt zu beladen)
|
||||
/// * `later` — Standardlager fertig, aber Filial-Items offen
|
||||
/// * `paused` — Lieferung explizit pausiert (`held`)
|
||||
/// * `done` — komplett fertig (Standardlager fertig & kein Filiale)
|
||||
/// * `canceled` — endgültig abgebrochen
|
||||
enum _OverviewBucket { open, later, paused, done, canceled }
|
||||
|
||||
/// Bündelt eine Lieferung mit ihrer Beladereihenfolge-Position für die
|
||||
/// Übersicht — der `originalIndex` bleibt über die Sektions-Umsortierung
|
||||
/// hinweg sichtbar im Tile-Avatar.
|
||||
class _OverviewEntry {
|
||||
const _OverviewEntry({
|
||||
required this.originalIndex,
|
||||
required this.delivery,
|
||||
required this.standardItems,
|
||||
});
|
||||
|
||||
final int originalIndex;
|
||||
final Delivery delivery;
|
||||
final List<DeliveryItem> standardItems;
|
||||
}
|
||||
|
||||
/// Sektion in der Übersicht mit eigenem Header + zugehörigen Tiles.
|
||||
/// Header zeigt Titel, farbigen Pill mit Anzahl und nimmt die Section-
|
||||
/// Farbe als Akzent — Fahrer erkennt auf einen Blick, „was kommt jetzt".
|
||||
class _BucketSection extends StatelessWidget {
|
||||
const _BucketSection({
|
||||
required this.title,
|
||||
required this.count,
|
||||
required this.color,
|
||||
required this.entries,
|
||||
required this.details,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final int count;
|
||||
final Color color;
|
||||
final List<_OverviewEntry> entries;
|
||||
final TourDetails details;
|
||||
|
||||
/// Optionales Icon vor dem Titel — wird aktuell nur für „Nächste
|
||||
/// Lieferung" verwendet, damit diese Sektion auf einen Blick als
|
||||
/// „aktiv jetzt" erkennbar ist. Bei den restlichen Sektionen reichen
|
||||
/// Farb-Balken + Pill als visueller Anker.
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final entry in entries)
|
||||
_OverviewTile(
|
||||
position: entry.originalIndex + 1,
|
||||
delivery: entry.delivery,
|
||||
standardItems: entry.standardItems,
|
||||
details: details,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LoadingCustomerPage(
|
||||
initialIndex: entry.originalIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hinweis-Badge unterhalb der Status-Zeile einer Lieferung mit Artikeln
|
||||
/// aus einem oder mehreren Filialen.
|
||||
///
|
||||
/// Formuliert bewusst „Enthält auch … zum späteren Abholen": Die Lieferung
|
||||
/// wird JETZT normal (im Standardlager) beladen — das Filial-Item ist
|
||||
/// nur ein zusätzlicher Bestandteil. Das „auch" signalisiert den Zusatz,
|
||||
/// und „zum späteren Abholen" hängt den Zeitbezug klar an den ARTIKEL, nicht
|
||||
/// an die Lieferung (die sonst fälschlich als nach-hinten-geschoben wirkt).
|
||||
class _ExternalWarehouseBadge extends StatelessWidget {
|
||||
const _ExternalWarehouseBadge({required this.labels});
|
||||
|
||||
@ -373,33 +804,44 @@ class _ExternalWarehouseBadge extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = labels.isEmpty
|
||||
? "Außenlager"
|
||||
: "Außenlager: ${labels.join(", ")}";
|
||||
final lagerText = labels.isEmpty
|
||||
? 'Artikel zum späteren Abholen aus der Filiale'
|
||||
: 'Artikel zum späteren Abholen aus Filiale: ${labels.join(", ")}';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.deepOrange.withValues(alpha: 0.15),
|
||||
color: Colors.amber.withValues(alpha: 0.22),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.deepOrange.withValues(alpha: 0.6)),
|
||||
border: Border.all(color: Colors.amber.withValues(alpha: 0.7)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.warehouse_outlined,
|
||||
size: 14, color: Colors.deepOrange.shade700),
|
||||
Icon(
|
||||
Icons.warehouse_outlined,
|
||||
size: 14,
|
||||
color: Colors.amber.shade800,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.deepOrange.shade800,
|
||||
),
|
||||
child: RichText(
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.amber.shade800,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(
|
||||
text: 'Enthält auch ',
|
||||
style: TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
TextSpan(text: lagerText),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -421,7 +863,7 @@ class _EmptyOverview extends StatelessWidget {
|
||||
Icon(Icons.inbox_outlined, size: 64, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
"Keine Lieferungen zum Beladen",
|
||||
'Keine Lieferungen zum Beladen',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
|
||||
@ -1,68 +0,0 @@
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
/// Hilfen rund um die Belade-Reihenfolge.
|
||||
///
|
||||
/// Die Beladereihenfolge ist die *Umkehrung* der vom Fahrer bestätigten
|
||||
/// Auslieferungs-Reihenfolge (Sortier-Phase): wer zuletzt ausgeliefert wird,
|
||||
/// kommt zuerst auf den LKW und liegt hinten. Diese Klasse liefert die
|
||||
/// gefilterte und gespiegelte ID-Liste — Quelle ist immer
|
||||
/// `TourLoaded.sortingInformation[carId]`.
|
||||
///
|
||||
/// Filterung:
|
||||
/// * Bei ≥2 Fahrzeugen im Team: nur Lieferungen mit
|
||||
/// `delivery.carId == selectedCarId`.
|
||||
/// * Bei genau 1 Fahrzeug: alle Tour-Lieferungen.
|
||||
///
|
||||
/// Konsistent mit der bestehenden Logik in [DeliverySortPage] und der
|
||||
/// alten `scan_page.dart:792`.
|
||||
class LoadingOrder {
|
||||
const LoadingOrder._();
|
||||
|
||||
/// Berechnet die Belade-Reihenfolge an Delivery-IDs.
|
||||
///
|
||||
/// [carIdStr] ist das String-Pendant der gewählten Auto-ID, weil die
|
||||
/// `sortingInformation` mit String-Keys arbeitet.
|
||||
static List<String> computeForCar({
|
||||
required TourLoaded state,
|
||||
required String carIdStr,
|
||||
}) {
|
||||
final cars = state.tour.driver.cars;
|
||||
final allowedIds = cars.length >= 2
|
||||
? state.tour.deliveries
|
||||
.where((d) => d.carId?.toString() == carIdStr)
|
||||
.map((d) => d.id)
|
||||
.toSet()
|
||||
: state.tour.deliveries.map((d) => d.id).toSet();
|
||||
|
||||
final raw = state.sortingInformation[carIdStr] ?? const <String>[];
|
||||
|
||||
// Mit reversed nach hinten kommt die zuletzt ausgelieferte Lieferung
|
||||
// nach vorne (zuerst beladen).
|
||||
final reversed = raw.reversed.where(allowedIds.contains).toList();
|
||||
|
||||
// Falls die Sortierung leer ist (kann bei frisch geladener Tour
|
||||
// vorkommen, bevor `EnsureSortingForCarEvent` durchlief), fallen wir
|
||||
// auf die unsortierten Tour-IDs zurück — der Fahrer sieht so wenigstens
|
||||
// alle Kunden, ohne dass die Page hängt.
|
||||
if (reversed.isEmpty && allowedIds.isNotEmpty) {
|
||||
return allowedIds.toList(growable: false);
|
||||
}
|
||||
return reversed;
|
||||
}
|
||||
|
||||
/// Komfort-Variante, die zusätzlich abgeschlossene Lieferungen rausfiltert
|
||||
/// (für Anzeigen, die nur "noch zu beladen" bzw. aktive Einträge möchten).
|
||||
static List<String> computeActive({
|
||||
required TourLoaded state,
|
||||
required String carIdStr,
|
||||
}) {
|
||||
final order = computeForCar(state: state, carIdStr: carIdStr);
|
||||
final byId = {for (final d in state.tour.deliveries) d.id: d};
|
||||
return order.where((id) {
|
||||
final d = byId[id];
|
||||
if (d == null) return false;
|
||||
return d.state != DeliveryState.finished;
|
||||
}).toList(growable: false);
|
||||
}
|
||||
}
|
||||
@ -1,535 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hl_lieferservice/model/article.dart';
|
||||
import 'package:hl_lieferservice/model/component.dart';
|
||||
|
||||
/// Identifier-Helpers für den Hold-State-Set: ein Artikel ohne Komponenten
|
||||
/// wird mit seiner [Article.internalId] referenziert, eine Komponente mit
|
||||
/// `<articleInternalId>:<componentArticleNumber>`.
|
||||
///
|
||||
/// Wir nutzen bewusst ein einfaches String-Schema statt einer eigenen Klasse,
|
||||
/// weil der Set-Lookup in jedem Row-Rebuild stattfindet und Sets von
|
||||
/// einfachen Strings am preisgünstigsten sind.
|
||||
class HoldKey {
|
||||
/// Schlüssel für einen ganzen (Nicht-Parent-)Artikel.
|
||||
static String article(Article a) => "art:${a.internalId}";
|
||||
|
||||
/// Schlüssel für eine Komponente (Stücklisten-Position) unterhalb eines
|
||||
/// Parent-Artikels.
|
||||
static String component(Article parent, Component c) =>
|
||||
"comp:${parent.internalId}:${c.articleNumber}";
|
||||
}
|
||||
|
||||
/// Visuelle Konstanten für die "Heute zurückgehalten"-Markierung.
|
||||
const _holdBadgeColor = Colors.deepOrange;
|
||||
|
||||
/// Renderer für eine Artikelzeile innerhalb der Beladen-Phase.
|
||||
///
|
||||
/// Unterscheidet automatisch zwischen Parent-Artikel (Stückliste) und
|
||||
/// regulärem Artikel — die Komponenten werden in einem [ParentArticleRow]
|
||||
/// inkl. Liste von [ComponentRow] aufgeklappt dargestellt. Außerhalb dieser
|
||||
/// Klasse sollte nur [ArticleRow] direkt verwendet werden; die anderen
|
||||
/// beiden Widgets sind als Subkomponenten exportiert, falls jemand sie
|
||||
/// gezielt ansteuern möchte.
|
||||
class ArticleRow extends StatelessWidget {
|
||||
const ArticleRow({
|
||||
super.key,
|
||||
required this.article,
|
||||
required this.isHeld,
|
||||
required this.disabled,
|
||||
this.heldComponents = const <String>{},
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
/// Der darzustellende Artikel.
|
||||
final Article article;
|
||||
|
||||
/// `true`, wenn der Artikel als Ganzes für heute zurückgehalten ist.
|
||||
/// Bei Parent-Artikeln wird dies an die Komponenten weitergereicht.
|
||||
final bool isHeld;
|
||||
|
||||
/// `true`, wenn die Lieferung selbst (z. B. wegen Abbruch) deaktiviert
|
||||
/// ist — die Zeile wird grundsätzlich ausgegraut, Tap deaktiviert.
|
||||
final bool disabled;
|
||||
|
||||
/// Set der gehaltenen Komponenten-Schlüssel (siehe [HoldKey.component]).
|
||||
/// Wird nur ausgewertet, wenn der Artikel ein Parent ist.
|
||||
final Set<String> heldComponents;
|
||||
|
||||
/// Optional: Tap-Callback, z. B. um den Artikel "manuell" zu inkrementieren.
|
||||
/// Bleibt für die Beladen-Phase aktuell `null` — der Scan-Flow geht über
|
||||
/// den Scanner, nicht den Tap. Lässt aber Raum für spätere Komfort-Aktionen.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Optional: Long-Press, z. B. für ein Kontext-Menü (Unscan).
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (article.isParent && article.components.isNotEmpty) {
|
||||
return ParentArticleRow(
|
||||
article: article,
|
||||
parentHeld: isHeld,
|
||||
disabled: disabled,
|
||||
heldComponents: heldComponents,
|
||||
);
|
||||
}
|
||||
return _RegularArticleRow(
|
||||
article: article,
|
||||
isHeld: isHeld,
|
||||
disabled: disabled,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reguläre Artikel-Zeile (ohne Stückliste) als Card.
|
||||
class _RegularArticleRow extends StatelessWidget {
|
||||
const _RegularArticleRow({
|
||||
required this.article,
|
||||
required this.isHeld,
|
||||
required this.disabled,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
final Article article;
|
||||
final bool isHeld;
|
||||
final bool disabled;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entryDone = article.isFullyScanned;
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final effectiveDisabled = disabled || isHeld;
|
||||
|
||||
// Card-Styling abhängig vom Status: gescannt = grünlicher Akzent,
|
||||
// zurückgehalten = orange-Akzent, sonst neutral. So sieht der Fahrer
|
||||
// beim Scrollen ohne Lesen, was schon erledigt ist.
|
||||
final Color cardColor;
|
||||
final Color borderColor;
|
||||
final IconData leadingIcon;
|
||||
final Color leadingColor;
|
||||
|
||||
if (isHeld) {
|
||||
cardColor = _holdBadgeColor.withValues(alpha: 0.07);
|
||||
borderColor = _holdBadgeColor.withValues(alpha: 0.45);
|
||||
leadingIcon = Icons.pause_circle_outline;
|
||||
leadingColor = _holdBadgeColor;
|
||||
} else if (entryDone) {
|
||||
cardColor = Colors.green.withValues(alpha: 0.07);
|
||||
borderColor = Colors.green.withValues(alpha: 0.45);
|
||||
leadingIcon = Icons.check_circle;
|
||||
leadingColor = Colors.green.shade700;
|
||||
} else {
|
||||
cardColor = scheme.surfaceContainerLow;
|
||||
borderColor = scheme.outlineVariant.withValues(alpha: 0.4);
|
||||
leadingIcon = Icons.inventory_2_outlined;
|
||||
leadingColor = scheme.onSurfaceVariant;
|
||||
}
|
||||
|
||||
return Opacity(
|
||||
opacity: effectiveDisabled ? 0.45 : 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: effectiveDisabled ? null : onTap,
|
||||
onLongPress: effectiveDisabled ? null : onLongPress,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: leadingColor.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(leadingIcon, color: leadingColor, size: 20),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
article.name,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: isHeld
|
||||
? TextDecoration.lineThrough
|
||||
: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
"Artikelnr. ${article.articleNumber}",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ScanCountBadge(
|
||||
done: article.scannedAmount + article.scannedRemovedAmount,
|
||||
total: article.amount,
|
||||
isComplete: entryDone,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isHeld) ...[
|
||||
const SizedBox(height: 8),
|
||||
const _HeldBadge(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parent-Artikel (Stückliste) — zeigt eine Header-Zeile und darunter die
|
||||
/// einzelnen Komponenten als [ComponentRow].
|
||||
class ParentArticleRow extends StatelessWidget {
|
||||
const ParentArticleRow({
|
||||
super.key,
|
||||
required this.article,
|
||||
required this.parentHeld,
|
||||
required this.disabled,
|
||||
this.heldComponents = const <String>{},
|
||||
});
|
||||
|
||||
/// Der Parent-Artikel (muss `isParent == true` und `components.isNotEmpty`).
|
||||
final Article article;
|
||||
|
||||
/// `true`, wenn der gesamte Parent-Artikel zurückgehalten ist
|
||||
/// (vererbt sich auf alle Komponenten).
|
||||
final bool parentHeld;
|
||||
|
||||
/// `true`, wenn die Lieferung deaktiviert ist (z. B. abgebrochen).
|
||||
final bool disabled;
|
||||
|
||||
/// Set der gehaltenen Komponenten-Schlüssel (siehe [HoldKey.component]).
|
||||
final Set<String> heldComponents;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final allDone = article.isFullyScanned;
|
||||
final scannedCount =
|
||||
article.components.where((c) => c.isFullyScanned).length;
|
||||
final effectiveDisabled = disabled || parentHeld;
|
||||
|
||||
// Card-Styling für Stückliste — gleiche Logik wie reguläre Artikel,
|
||||
// aber mit Stücklisten-Icon und der Komponenten-Liste innerhalb derselben
|
||||
// Card (visuell gruppiert).
|
||||
final Color cardColor;
|
||||
final Color borderColor;
|
||||
final IconData headerIcon;
|
||||
final Color headerIconColor;
|
||||
|
||||
if (parentHeld) {
|
||||
cardColor = _holdBadgeColor.withValues(alpha: 0.07);
|
||||
borderColor = _holdBadgeColor.withValues(alpha: 0.45);
|
||||
headerIcon = Icons.pause_circle_outline;
|
||||
headerIconColor = _holdBadgeColor;
|
||||
} else if (allDone) {
|
||||
cardColor = Colors.green.withValues(alpha: 0.07);
|
||||
borderColor = Colors.green.withValues(alpha: 0.45);
|
||||
headerIcon = Icons.check_circle;
|
||||
headerIconColor = Colors.green.shade700;
|
||||
} else {
|
||||
cardColor = scheme.surfaceContainerLow;
|
||||
borderColor = scheme.outlineVariant.withValues(alpha: 0.4);
|
||||
headerIcon = Icons.account_tree_outlined;
|
||||
headerIconColor = scheme.primary;
|
||||
}
|
||||
|
||||
return Opacity(
|
||||
opacity: effectiveDisabled ? 0.45 : 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: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header-Reihe mit Icon, Name, Komponenten-Counter.
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: headerIconColor.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child:
|
||||
Icon(headerIcon, color: headerIconColor, size: 20),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
article.name,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: parentHeld
|
||||
? TextDecoration.lineThrough
|
||||
: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
"Stückliste · $scannedCount/${article.components.length} Komponenten",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
allDone ? Icons.check_circle : Icons.pending_outlined,
|
||||
color: allDone ? Colors.green : Colors.orange,
|
||||
size: 22,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (parentHeld) ...[
|
||||
const SizedBox(height: 8),
|
||||
const _HeldBadge(),
|
||||
],
|
||||
if (article.components.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
...article.components.map(
|
||||
(c) => ComponentRow(
|
||||
component: c,
|
||||
parentArticle: article,
|
||||
isHeld: parentHeld ||
|
||||
heldComponents.contains(HoldKey.component(article, c)),
|
||||
disabled: disabled,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine einzelne Komponenten-Zeile (Position einer Stückliste).
|
||||
class ComponentRow extends StatelessWidget {
|
||||
const ComponentRow({
|
||||
super.key,
|
||||
required this.component,
|
||||
required this.parentArticle,
|
||||
required this.isHeld,
|
||||
required this.disabled,
|
||||
});
|
||||
|
||||
/// Die Komponente.
|
||||
final Component component;
|
||||
|
||||
/// Parent-Artikel zur Auflösung des Hold-Keys & Anzeige-Kontextes.
|
||||
final Article parentArticle;
|
||||
|
||||
/// `true`, wenn diese Komponente (oder der Parent) zurückgehalten ist.
|
||||
final bool isHeld;
|
||||
|
||||
/// `true`, wenn die Lieferung deaktiviert ist.
|
||||
final bool disabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final done = component.isFullyScanned;
|
||||
final effectiveDisabled = disabled || isHeld;
|
||||
|
||||
// Component-Reihe sitzt INNERHALB der Parent-Card — daher kein eigener
|
||||
// Card-Wrapper. Stattdessen klare Einrückung + dezente Status-Markierung.
|
||||
final Color iconColor = done
|
||||
? Colors.green.shade700
|
||||
: (isHeld ? _holdBadgeColor : scheme.onSurfaceVariant);
|
||||
final IconData icon = isHeld
|
||||
? Icons.pause_circle_outline
|
||||
: (done ? Icons.check_circle : Icons.radio_button_unchecked);
|
||||
|
||||
return Opacity(
|
||||
opacity: effectiveDisabled ? 0.45 : 1.0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(48, 6, 4, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: iconColor, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
component.name,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
decoration: isHeld
|
||||
? TextDecoration.lineThrough
|
||||
: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"Artikelnr. ${component.articleNumber}",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_ScanCountBadge(
|
||||
done: component.scannedAmount,
|
||||
total: component.requiredAmount,
|
||||
isComplete: done,
|
||||
compact: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isHeld) ...[
|
||||
const SizedBox(height: 4),
|
||||
const _HeldBadge(indented: true),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kompaktes Mengen-Badge `x / y×` für Artikel-/Komponenten-Karten.
|
||||
/// `compact: true` reduziert Padding und Schriftgröße für die Verwendung
|
||||
/// innerhalb der Parent-Card.
|
||||
class _ScanCountBadge extends StatelessWidget {
|
||||
const _ScanCountBadge({
|
||||
required this.done,
|
||||
required this.total,
|
||||
required this.isComplete,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final int done;
|
||||
final int total;
|
||||
final bool isComplete;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final color = isComplete ? Colors.green.shade700 : scheme.primary;
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 8 : 10,
|
||||
vertical: compact ? 3 : 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
"$done / $total×",
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 11 : 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeldBadge extends StatelessWidget {
|
||||
const _HeldBadge({this.indented = false});
|
||||
|
||||
/// Linke Einrückung — für Komponenten unter dem Parent-Header in der Card.
|
||||
final bool indented;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: indented ? 28 : 0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: _holdBadgeColor.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: _holdBadgeColor.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(Icons.pause_circle_outline,
|
||||
size: 12, color: _holdBadgeColor),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
"Heute zurückgehalten",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _holdBadgeColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,243 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hl_lieferservice/feature/loading/widget/article_row.dart';
|
||||
import 'package:hl_lieferservice/model/article.dart';
|
||||
import 'package:hl_lieferservice/model/component.dart';
|
||||
|
||||
/// Eine einzelne, im Hold-Dialog auswählbare Position. Aufrufer erhalten
|
||||
/// nach Bestätigung die ausgewählten Items zurück.
|
||||
///
|
||||
/// Genau eines von [article] / [component] ist gesetzt — beide kombiniert
|
||||
/// ergeben einen Komponenten-Eintrag (component != null mit zugehörigem
|
||||
/// Parent-Artikel in [article]).
|
||||
class HoldSelectionItem {
|
||||
HoldSelectionItem.article(this.article)
|
||||
: component = null,
|
||||
key = HoldKey.article(article);
|
||||
|
||||
HoldSelectionItem.component(this.article, Component this.component)
|
||||
: key = HoldKey.component(article, component);
|
||||
|
||||
/// Artikel — bei Komponenten der zugehörige Parent.
|
||||
final Article article;
|
||||
|
||||
/// Komponente (nur gesetzt, wenn es sich um eine Stücklisten-Position
|
||||
/// handelt).
|
||||
final Component? component;
|
||||
|
||||
/// Eindeutiger Schlüssel zur Hold-State-Verwaltung. Identisch mit den
|
||||
/// Keys, die [HoldKey] erzeugt — so kann ein Aufrufer ohne Umweg den
|
||||
/// internen Hold-Set füllen.
|
||||
final String key;
|
||||
|
||||
String get _displayName => component?.name ?? article.name;
|
||||
|
||||
String get _articleNumber =>
|
||||
component?.articleNumber ?? article.articleNumber;
|
||||
}
|
||||
|
||||
/// Auswahl-Dialog für den Teilabbruch ("Artikel heute nicht liefern").
|
||||
///
|
||||
/// Liefert nach Bestätigung per `Navigator.pop` die Liste der ausgewählten
|
||||
/// [HoldSelectionItem]s. Bei Abbruch ist das Ergebnis `null`. Items, die
|
||||
/// im Set [alreadyHeld] enthalten sind, werden ausgegraut dargestellt und
|
||||
/// sind nicht erneut wählbar.
|
||||
class HoldSelectionDialog extends StatefulWidget {
|
||||
const HoldSelectionDialog({
|
||||
super.key,
|
||||
required this.customerName,
|
||||
required this.articles,
|
||||
required this.alreadyHeld,
|
||||
});
|
||||
|
||||
/// Anzeigename des Kunden — wird im Dialog-Header gezeigt.
|
||||
final String customerName;
|
||||
|
||||
/// Scannbare Artikel der Lieferung (also bereits vorgefiltert).
|
||||
final List<Article> articles;
|
||||
|
||||
/// Set bereits gehaltener Keys — diese erscheinen ausgegraut & disabled.
|
||||
final Set<String> alreadyHeld;
|
||||
|
||||
static Future<List<HoldSelectionItem>?> show(
|
||||
BuildContext context, {
|
||||
required String customerName,
|
||||
required List<Article> articles,
|
||||
required Set<String> alreadyHeld,
|
||||
}) {
|
||||
return showDialog<List<HoldSelectionItem>>(
|
||||
context: context,
|
||||
builder: (_) => HoldSelectionDialog(
|
||||
customerName: customerName,
|
||||
articles: articles,
|
||||
alreadyHeld: alreadyHeld,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<HoldSelectionDialog> createState() => _HoldSelectionDialogState();
|
||||
}
|
||||
|
||||
class _HoldSelectionDialogState extends State<HoldSelectionDialog> {
|
||||
final Set<String> _selectedKeys = <String>{};
|
||||
late final List<HoldSelectionItem> _items;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_items = _buildItems(widget.articles);
|
||||
}
|
||||
|
||||
/// Erzeugt aus den Artikeln die selektierbaren Einträge. Parent-Artikel
|
||||
/// werden nicht selbst zum Eintrag — ihre Komponenten sind die wählbaren
|
||||
/// Einheiten. Für die Anzeige der Header-Zeile werden Parents über das
|
||||
/// Build-Verfahren (siehe build) separat eingestreut.
|
||||
List<HoldSelectionItem> _buildItems(List<Article> articles) {
|
||||
final result = <HoldSelectionItem>[];
|
||||
for (final a in articles) {
|
||||
if (a.isParent && a.components.isNotEmpty) {
|
||||
for (final c in a.components) {
|
||||
result.add(HoldSelectionItem.component(a, c));
|
||||
}
|
||||
} else {
|
||||
result.add(HoldSelectionItem.article(a));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void _toggle(String key) {
|
||||
setState(() {
|
||||
if (_selectedKeys.contains(key)) {
|
||||
_selectedKeys.remove(key);
|
||||
} else {
|
||||
_selectedKeys.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _confirm() {
|
||||
final selected =
|
||||
_items.where((i) => _selectedKeys.contains(i.key)).toList();
|
||||
Navigator.of(context).pop(selected);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text("Artikel zurückhalten"),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.customerName,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
"Markiere die Positionen, die heute nicht ausgeliefert werden:",
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: _buildList(theme),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text("Abbrechen"),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _selectedKeys.isEmpty ? null : _confirm,
|
||||
child: const Text("Weiter"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Baut die ListView-Inhalte mit Header-Zeilen für Parent-Artikel.
|
||||
/// Parent-Header sind bewusst nicht klickbar — sie dienen nur zur
|
||||
/// Strukturierung.
|
||||
List<Widget> _buildList(ThemeData theme) {
|
||||
final widgets = <Widget>[];
|
||||
|
||||
for (final a in widget.articles) {
|
||||
if (a.isParent && a.components.isNotEmpty) {
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 2, left: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.account_tree_outlined,
|
||||
size: 16, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
a.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final c in a.components) {
|
||||
final item = _items.firstWhere(
|
||||
(i) => i.component == c && i.article == a,
|
||||
);
|
||||
widgets.add(_buildTile(item, indent: true));
|
||||
}
|
||||
} else {
|
||||
final item = _items.firstWhere(
|
||||
(i) => i.article == a && i.component == null,
|
||||
);
|
||||
widgets.add(_buildTile(item));
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
Widget _buildTile(HoldSelectionItem item, {bool indent = false}) {
|
||||
final alreadyHeld = widget.alreadyHeld.contains(item.key);
|
||||
final selected = _selectedKeys.contains(item.key);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: indent ? 16 : 0),
|
||||
child: Opacity(
|
||||
opacity: alreadyHeld ? 0.4 : 1.0,
|
||||
child: CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
value: alreadyHeld ? true : selected,
|
||||
onChanged: alreadyHeld ? null : (_) => _toggle(item.key),
|
||||
title: Text(
|
||||
item._displayName,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
"Artikelnr. ${item._articleNumber}"
|
||||
"${alreadyHeld ? " · bereits zurückgehalten" : ""}",
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
46
lib/feature/loading/widget/reason_catalog.dart
Normal file
46
lib/feature/loading/widget/reason_catalog.dart
Normal file
@ -0,0 +1,46 @@
|
||||
/// Vordefinierte Reason-Listen pro Action-Typ.
|
||||
///
|
||||
/// Bewusst hier zentral statt jede UI-Stelle ihren eigenen Strings
|
||||
/// definieren zu lassen — Konsistenz für den Fahrer und einfache
|
||||
/// nachträgliche Anpassung (eine Datei, ein PR).
|
||||
///
|
||||
/// Jede Liste enthält die fachlich häufigsten Gründe. „Anderer Grund"
|
||||
/// erscheint im Picker zusätzlich als Freitext-Fallback und ist nicht
|
||||
/// Teil der Konstanten — der Picker fügt ihn selbst an.
|
||||
class ReasonCatalog {
|
||||
const ReasonCatalog._();
|
||||
|
||||
/// Gründe für `POST /deliveries/{id}/cancel`. Endgültig, deshalb
|
||||
/// bewusst Gründe, die einen erneuten Liefer-Versuch sinnlos machen.
|
||||
static const List<String> deliveryCancel = [
|
||||
'Adresse falsch',
|
||||
'Kunde unbekannt',
|
||||
'Kunde nicht erreichbar',
|
||||
'Termin endgültig verpasst',
|
||||
'Ware nicht verfügbar',
|
||||
];
|
||||
|
||||
/// Gründe für `POST /deliveries/{id}/hold`. Reversibel — typischerweise
|
||||
/// „kommt später nochmal" oder „muss intern geklärt werden".
|
||||
static const List<String> deliveryHold = [
|
||||
'Kunde nicht zu Hause',
|
||||
'Termin verschoben',
|
||||
'Wartet auf Rückruf',
|
||||
];
|
||||
|
||||
/// Gründe für `POST /scans action=remove`. Item wird aus der Lieferung
|
||||
/// genommen, kommt nicht mit.
|
||||
static const List<String> itemRemove = [
|
||||
'Artikel defekt',
|
||||
'Artikel nicht vorhanden',
|
||||
'Falscher Artikel im Lager',
|
||||
'Falsch gescannt',
|
||||
];
|
||||
|
||||
/// Gründe für `POST /scans action=hold`. Item kurzfristig zurückgestellt.
|
||||
static const List<String> itemHold = [
|
||||
'Lager findet Ware nicht',
|
||||
'Ware wird geprüft',
|
||||
'Zusatzklärung nötig',
|
||||
];
|
||||
}
|
||||
@ -1,140 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Vordefinierte Gründe für Abbruch / Teilabbruch.
|
||||
///
|
||||
/// Die Liste ist absichtlich kurz und fahrernah gehalten — wir fragen keine
|
||||
/// Mini-Romane ab, sondern erlauben das Wichtigste mit einem Tap. Für alles
|
||||
/// Sonstige steht "Anderer Grund" mit Freitext zur Verfügung.
|
||||
const List<String> _predefinedReasons = [
|
||||
"Kunde nicht erreichbar",
|
||||
"Adresse falsch",
|
||||
"Ware beschädigt",
|
||||
"Zugang nicht möglich",
|
||||
"Anderer Grund",
|
||||
];
|
||||
|
||||
/// Schlüssel-Konstante für die "Anderer Grund"-Option — damit Aufrufer den
|
||||
/// Vergleich nicht über String-Literals führen müssen.
|
||||
const String _otherReasonOption = "Anderer Grund";
|
||||
|
||||
/// Wiederverwendbarer Grund-Dialog für Beladen-Phase: sowohl der komplette
|
||||
/// Lieferungs-Abbruch als auch das Zurückhalten einzelner Artikel /
|
||||
/// Komponenten landen in diesem Picker.
|
||||
///
|
||||
/// Liefert per `showDialog<String>` den finalen Grundtext zurück — also
|
||||
/// entweder einen der vordefinierten Strings oder den vom Fahrer
|
||||
/// eingegebenen Freitext. Bei Abbruch des Dialogs ist das Ergebnis `null`.
|
||||
class ReasonPickerDialog extends StatefulWidget {
|
||||
const ReasonPickerDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
/// Anzeigetitel des Dialogs (z. B. "Lieferung abbrechen").
|
||||
final String title;
|
||||
|
||||
/// Optionaler erläuternder Untertitel (z. B. Name des Kunden).
|
||||
final String? subtitle;
|
||||
|
||||
/// Komfort-Helfer: zeigt den Dialog und liefert das Ergebnis. Aufrufer
|
||||
/// müssen so nicht mehr selbst `showDialog<String>` mit dem Builder
|
||||
/// instanziieren.
|
||||
static Future<String?> show(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
String? subtitle,
|
||||
}) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => ReasonPickerDialog(title: title, subtitle: subtitle),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ReasonPickerDialog> createState() => _ReasonPickerDialogState();
|
||||
}
|
||||
|
||||
class _ReasonPickerDialogState extends State<ReasonPickerDialog> {
|
||||
String? _selected;
|
||||
final TextEditingController _freeText = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_freeText.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _isOther => _selected == _otherReasonOption;
|
||||
|
||||
bool get _canConfirm {
|
||||
if (_selected == null) return false;
|
||||
if (_isOther) return _freeText.text.trim().isNotEmpty;
|
||||
return true;
|
||||
}
|
||||
|
||||
void _confirm() {
|
||||
if (!_canConfirm) return;
|
||||
final reason = _isOther ? _freeText.text.trim() : _selected!;
|
||||
Navigator.of(context).pop(reason);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.subtitle != null) ...[
|
||||
Text(
|
||||
widget.subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
..._predefinedReasons.map((reason) {
|
||||
return RadioListTile<String>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text(reason),
|
||||
value: reason,
|
||||
groupValue: _selected,
|
||||
onChanged: (val) => setState(() => _selected = val),
|
||||
);
|
||||
}),
|
||||
if (_isOther)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: TextField(
|
||||
controller: _freeText,
|
||||
autofocus: true,
|
||||
maxLines: 3,
|
||||
minLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Bitte Grund angeben",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text("Abbrechen"),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _canConfirm ? _confirm : null,
|
||||
child: const Text("Bestätigen"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
225
lib/feature/loading/widget/reason_picker_sheet.dart
Normal file
225
lib/feature/loading/widget/reason_picker_sheet.dart
Normal file
@ -0,0 +1,225 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Ergebnis des Reason-Pickers: getrimmter Grund + optionale Menge.
|
||||
class ReasonPickerResult {
|
||||
const ReasonPickerResult({required this.reason, this.quantity});
|
||||
|
||||
final String reason;
|
||||
|
||||
/// Gewählte Menge (1…maxQuantity). `null`, wenn das Sheet **ohne**
|
||||
/// Mengen-Auswahl geöffnet wurde (`maxQuantity == null`, z. B. bei
|
||||
/// Lieferungs-Gründen wie Pausieren/Abbrechen) — dann gilt „ganze Zeile".
|
||||
final int? quantity;
|
||||
}
|
||||
|
||||
/// Bottom-Sheet zur Auswahl einer Begründung. Zeigt eine Radio-Liste der
|
||||
/// vordefinierten Gründe + den Sonderfall „Anderer Grund", der ein
|
||||
/// Freitext-Feld einblendet.
|
||||
///
|
||||
/// Optional — wenn [maxQuantity] gesetzt und > 1 — zusätzlich eine
|
||||
/// **Mengen-Auswahl** (Stepper 1…maxQuantity, Vorbelegung = maxQuantity) für
|
||||
/// die Teilmengen-Löschung/-Gutschrift.
|
||||
///
|
||||
/// Rückgabe: [ReasonPickerResult] oder `null` bei Abbruch.
|
||||
///
|
||||
/// Validierung:
|
||||
/// * Vordefinierter Grund → direkt OK
|
||||
/// * „Anderer Grund" mit leerem Freitext → Bestätigen disabled
|
||||
Future<ReasonPickerResult?> showReasonPickerSheet({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required List<String> presets,
|
||||
String confirmLabel = 'Übernehmen',
|
||||
int? maxQuantity,
|
||||
}) {
|
||||
return showModalBottomSheet<ReasonPickerResult>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (ctx) => _ReasonPickerSheet(
|
||||
title: title,
|
||||
presets: presets,
|
||||
confirmLabel: confirmLabel,
|
||||
maxQuantity: maxQuantity,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ReasonPickerSheet extends StatefulWidget {
|
||||
const _ReasonPickerSheet({
|
||||
required this.title,
|
||||
required this.presets,
|
||||
required this.confirmLabel,
|
||||
required this.maxQuantity,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<String> presets;
|
||||
final String confirmLabel;
|
||||
final int? maxQuantity;
|
||||
|
||||
@override
|
||||
State<_ReasonPickerSheet> createState() => _ReasonPickerSheetState();
|
||||
}
|
||||
|
||||
/// Sentinel-Wert für die „Anderer Grund"-Option. Eigene Klasse, damit der
|
||||
/// Vergleich nicht mit echten Reason-Strings kollidieren kann (z. B.
|
||||
/// jemand definiert tatsächlich „Anderer Grund" als Standard-Reason).
|
||||
const String _otherSentinel = ' other';
|
||||
|
||||
class _ReasonPickerSheetState extends State<_ReasonPickerSheet> {
|
||||
String? _selected;
|
||||
final _customController = TextEditingController();
|
||||
final _customFocus = FocusNode();
|
||||
late int _qty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Vorbelegung: ganze Restmenge.
|
||||
_qty = widget.maxQuantity ?? 1;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_customController.dispose();
|
||||
_customFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _isOther => _selected == _otherSentinel;
|
||||
|
||||
/// Mengen-Stepper nur zeigen, wenn überhaupt eine Auswahl Sinn ergibt.
|
||||
bool get _showQuantity => (widget.maxQuantity ?? 0) > 1;
|
||||
|
||||
String? get _effectiveReason {
|
||||
if (_selected == null) return null;
|
||||
if (_isOther) {
|
||||
final v = _customController.text.trim();
|
||||
return v.isEmpty ? null : v;
|
||||
}
|
||||
return _selected;
|
||||
}
|
||||
|
||||
void _onConfirm() {
|
||||
final reason = _effectiveReason;
|
||||
if (reason == null) return;
|
||||
Navigator.of(context).pop(
|
||||
ReasonPickerResult(
|
||||
reason: reason,
|
||||
// Ohne Mengen-Kontext (maxQuantity == null) → null = ganze Zeile.
|
||||
quantity: widget.maxQuantity == null ? null : _qty,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 8,
|
||||
// Tastatur-Höhe rein-clipping, damit das Freitext-Feld sichtbar bleibt.
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||
),
|
||||
// Scrollbar, damit Mengen-Stepper + Gründe + Freitext bei wenig Platz
|
||||
// (offene Tastatur) nicht overflowen.
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showQuantity) ...[
|
||||
Text(
|
||||
'Wie viele entfernen? (1–${widget.maxQuantity})',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
IconButton.filled(
|
||||
onPressed:
|
||||
_qty > 1 ? () => setState(() => _qty -= 1) : null,
|
||||
icon: const Icon(Icons.remove),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$_qty',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton.filled(
|
||||
onPressed: _qty < widget.maxQuantity!
|
||||
? () => setState(() => _qty += 1)
|
||||
: null,
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
],
|
||||
for (final preset in widget.presets)
|
||||
RadioListTile<String>(
|
||||
value: preset,
|
||||
groupValue: _selected,
|
||||
onChanged: (v) => setState(() => _selected = v),
|
||||
title: Text(preset),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
),
|
||||
RadioListTile<String>(
|
||||
value: _otherSentinel,
|
||||
groupValue: _selected,
|
||||
onChanged: (v) {
|
||||
setState(() => _selected = v);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_customFocus.requestFocus();
|
||||
});
|
||||
},
|
||||
title: const Text('Anderer Grund'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
),
|
||||
if (_isOther)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 8),
|
||||
child: TextField(
|
||||
controller: _customController,
|
||||
focusNode: _customFocus,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Begründung',
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _onConfirm(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(
|
||||
onPressed: _effectiveReason == null ? null : _onConfirm,
|
||||
child: Text(widget.confirmLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user