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:
535
lib/feature/loading/widget/article_row.dart
Normal file
535
lib/feature/loading/widget/article_row.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
140
lib/feature/loading/widget/reason_picker_dialog.dart
Normal file
140
lib/feature/loading/widget/reason_picker_dialog.dart
Normal 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"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user