Phasenbasierte Lieferübersicht + Beladen-Flow, plus Migrationsplan für Rust-Backend

UI-Restructuring:
- TabBar in scan_page durch dedizierte Phasen ersetzt: Sortieren / Beladen / Ausliefern
- PhaseBloc + PhaseService leiten Phase aus Tour-/Item-States ab
- DeliverySelectionPage (ab 2 Autos) und DeliverySortPage als eigene Flows
- LoadingOverviewPage / LoadingCustomerPage für die Beladephase
- PhaseStepper-Widget im Home für Phasen-Anzeige
- Lager-Differenzierung (Standardlager 0 vs. Außenlager) via WarehouseBadge

Process-Stubs:
- ProcessRepository für Hold/Cancel/Sort/Assign-Flows (stub, bereit für Backend-Anbindung)

Doku:
- docs/BACKEND_MIGRATION.md: Phasenplan für Umstellung auf das neue
  Rust-Backend (OpenAPI-Generator, Keycloak OIDC, Clean-Arch-Layering)
This commit is contained in:
Dennis Nemec
2026-05-14 22:27:56 +02:00
parent ac6b03227d
commit 456fb59668
29 changed files with 5425 additions and 1015 deletions

View File

@ -0,0 +1,115 @@
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

View File

@ -0,0 +1,431 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/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.
class LoadingOverviewPage extends StatelessWidget {
const LoadingOverviewPage({super.key});
String? _lookupCarPlate(int? carId, Tour tour) {
if (carId == null) return null;
return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate;
}
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;
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CarSelectBloc, CarSelectState>(
builder: (context, carState) {
final carIdStr =
carState is CarSelectComplete ? carState.selectedCar.id.toString() : "";
return BlocBuilder<TourBloc, TourState>(
builder: (context, tourState) {
if (tourState is TourLoadingFailed) {
return const DeliveryLoadingFailedPage();
}
if (tourState is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final groups = _buildGroups(tourState, carIdStr);
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(140),
child: PhaseStepper(
currentPhase: DeliveryPhase.beladen,
carId: carIdStr,
),
),
body: SafeArea(
top: false,
child: groups.isEmpty
? const _EmptyOverview()
: _OverviewList(groups: groups),
),
);
},
);
},
);
}
}
class _OverviewList extends StatelessWidget {
const _OverviewList({required this.groups});
final List<LoadingGroup> groups;
@override
Widget build(BuildContext context) {
final totalActive = groups
.where((g) => g.delivery.state != DeliveryState.canceled)
.length;
final doneActive = groups
.where((g) =>
g.delivery.state != DeliveryState.canceled && g.isComplete)
.length;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Beladereihenfolge",
style: Theme.of(context).textTheme.titleMedium,
),
Text(
"$doneActive / $totalActive Kunden",
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: totalActive == 0 ? 0.0 : doneActive / totalActive,
minHeight: 6,
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
doneActive == totalActive && totalActive > 0
? Colors.green
: Theme.of(context).primaryColor,
),
),
),
],
),
),
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),
),
);
},
);
},
),
),
],
);
}
}
class _OverviewTile extends StatelessWidget {
const _OverviewTile({
required this.position,
required this.group,
required this.onTap,
});
final int position;
final LoadingGroup group;
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;
// 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;
if (canceled) {
cardColor = Colors.grey.withValues(alpha: 0.08);
borderColor = Colors.grey.withValues(alpha: 0.35);
titleColor = Colors.grey.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";
statusIcon = Icons.warehouse_outlined;
} else if (isComplete) {
cardColor = Colors.green.withValues(alpha: 0.07);
borderColor = Colors.green.withValues(alpha: 0.35);
titleColor = Colors.green.shade700;
statusText = "Fertig beladen";
statusIcon = Icons.check_circle_outline;
} else if (isPartial) {
cardColor = Colors.orange.withValues(alpha: 0.07);
borderColor = Colors.orange.withValues(alpha: 0.35);
titleColor = Colors.orange.shade800;
statusText = "Beladung läuft";
statusIcon = Icons.pending_outlined;
} else {
cardColor = theme.colorScheme.surfaceContainerLow;
borderColor = Colors.transparent;
titleColor = theme.colorScheme.onSurface;
statusText = "Offen";
statusIcon = Icons.radio_button_unchecked;
}
// 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);
}
final progressLabel = canceled
? ""
: "${group.completeArticles}/${group.totalArticles} Artikel";
return Opacity(
opacity: canceled ? 0.65 : 1.0,
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: borderColor),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
CircleAvatar(
backgroundColor:
canceled ? Colors.grey : theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
radius: 18,
child: Text(
"$position",
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.delivery.customer.name,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: titleColor,
decoration: canceled
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
const SizedBox(height: 2),
Text(
group.delivery.customer.address.toString(),
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(statusIcon,
size: 14, color: titleColor),
),
const SizedBox(width: 4),
// Expanded, damit lange Status-Texte wie
// "Standardlager fertig — Außenlager offen"
// umbrechen statt zu überlaufen.
Expanded(
child: Text(
statusText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: titleColor,
),
softWrap: true,
),
),
const SizedBox(width: 10),
Text(
progressLabel,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
if (!canceled && hasExternalWarehouse) ...[
const SizedBox(height: 6),
_ExternalWarehouseBadge(
labels: group.externalWarehouseLabels,
),
],
],
),
),
const Icon(Icons.chevron_right),
],
),
),
),
),
);
}
}
/// 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 _ExternalWarehouseBadge extends StatelessWidget {
const _ExternalWarehouseBadge({required this.labels});
final List<String> labels;
@override
Widget build(BuildContext context) {
final text = labels.isEmpty
? "Außenlager"
: "Außenlager: ${labels.join(", ")}";
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.deepOrange.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.deepOrange.withValues(alpha: 0.6)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.warehouse_outlined,
size: 14, color: Colors.deepOrange.shade700),
const SizedBox(width: 4),
Flexible(
child: Text(
text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.deepOrange.shade800,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _EmptyOverview extends StatelessWidget {
const _EmptyOverview();
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inbox_outlined, size: 64, color: scheme.onSurfaceVariant),
const SizedBox(height: 12),
Text(
"Keine Lieferungen zum Beladen",
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
}
}

View File

@ -0,0 +1,68 @@
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);
}
}

View File

@ -0,0 +1,535 @@
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,
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,243 @@
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),
),
),
),
);
}
}

View File

@ -0,0 +1,140 @@
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"),
),
],
);
}
}