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:
243
lib/feature/loading/widget/hold_selection_dialog.dart
Normal file
243
lib/feature/loading/widget/hold_selection_dialog.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user