Files
Holzleitner-Lieferservice-App/lib/feature/loading/widget/article_row.dart
Dennis Nemec 456fb59668 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)
2026-05-14 22:27:56 +02:00

536 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
),
),
],
),
),
),
);
}
}