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)
536 lines
18 KiB
Dart
536 lines
18 KiB
Dart
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|