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

@ -11,7 +11,9 @@ import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart';
import 'package:hl_lieferservice/feature/cars/repository/cars_repository.dart';
import 'package:hl_lieferservice/feature/cars/service/cars_service.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.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/repository/tour_repository.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
@ -69,6 +71,20 @@ class _DeliveryAppState extends State<DeliveryApp> {
authBloc: context.read<AuthBloc>(),
),
),
BlocProvider(
// PhaseBloc darf erst NACH dem TourBloc gebaut werden,
// da er die Anzahl der Team-Fahrzeuge daraus liest, um
// beim ersten Load eines Fahrzeugs die korrekte
// Eintrittsphase (Auswählen vs. Sortieren) zu bestimmen.
create: (context) => PhaseBloc(
carCountResolver: () {
final tourState = context.read<TourBloc>().state;
return tourState is TourLoaded
? tourState.tour.driver.cars.length
: null;
},
),
),
],
child: MaterialApp(
// Wrap the Navigator (not just the home route) so the loading

View File

@ -2,18 +2,34 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.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/car_selection/presentation/selected_car_bar.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.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_overview_page.dart';
import 'package:hl_lieferservice/feature/scan/presentation/scan_page.dart';
import 'package:hl_lieferservice/feature/car_selection/presentation/selected_car_bar.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_selection_page.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_sort_page.dart';
import 'package:hl_lieferservice/feature/loading/presentation/loading_overview_page.dart';
import 'package:hl_lieferservice/feature/settings/presentation/settings_page.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_state.dart';
import 'package:hl_lieferservice/widget/navigation_bar/presentation/navigation_bar.dart';
/// Wurzel-Widget des authentifizierten Bereichs. Routet anhand der aktuellen
/// Phase des ausgewählten Fahrzeugs:
///
/// * Phase Sortieren / Beladen → die jeweilige Phase-Page wird direkt
/// gerendert (kein BottomNav). Navigation läuft über den Phasen-Stepper.
/// * Phase Ausliefern → klassisches Home mit BottomNavigationBar
/// (Auslieferung / Fahrzeuge / Einstellungen). Beladung als Tab entfällt,
/// da die Phase abgeschlossen ist.
class Home extends StatefulWidget {
const Home({super.key});
@ -22,46 +38,131 @@ class Home extends StatefulWidget {
}
class _HomeState extends State<Home> {
String? _initializedCarId;
@override
void initState() {
super.initState();
// Load deliveries
Authenticated state = context.read<AuthBloc>().state as Authenticated;
context.read<TourBloc>().add(LoadTour(teamId: state.user.number));
// Tour beim ersten Aufbau laden.
final authState = context.read<AuthBloc>().state as Authenticated;
context.read<TourBloc>().add(LoadTour(teamId: authState.user.number));
}
Widget _buildPage(index) {
if (index == 0) {
return ScanPage();
}
/// Stellt sicher, dass für das aktuell gewählte Auto eine Phase im
/// [PhaseBloc] existiert. Wird im build() reaktiv aufgerufen, daher mit
/// `_initializedCarId` gegen mehrfache Loads gesichert.
///
/// Wichtig: Wir feuern den Load erst, sobald die Tour geladen ist —
/// sonst kennt der PhaseBloc die Anzahl der Team-Fahrzeuge nicht und
/// würde fälschlich mit `sortieren` einsteigen, statt mit `auswaehlen`.
void _ensurePhaseLoaded(String carId) {
if (_initializedCarId == carId) return;
_initializedCarId = carId;
context.read<PhaseBloc>().add(PhaseLoadForCar(carId: carId));
}
if (index == 1) {
return DeliveryOverviewPage();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CarSelectBloc, CarSelectState>(
builder: (context, carState) {
// Ohne ausgewähltes Auto bleibt Home leer — der CarSelectionEnforcer
// legt die Selection-Page als Overlay darüber.
if (carState is! CarSelectComplete) {
return const Scaffold(body: SizedBox.shrink());
}
if (index == 2) {
return CarManagementPage();
}
final carId = carState.selectedCar.id.toString();
if (index == 3) {
return SettingsPage();
}
return BlocBuilder<TourBloc, TourState>(
// Tour-Status mitnehmen, weil die Eintrittsphase davon abhängt.
// Nur bei TourLoaded triggern wir den Phasen-Load.
builder: (context, tourState) {
if (tourState is TourLoaded) {
_ensurePhaseLoaded(carId);
}
return Container();
return BlocBuilder<PhaseBloc, PhaseState>(
builder: (context, phaseState) {
final phase = phaseState is PhaseReady
? phaseState.phaseFor(carId)
: null;
// Solange weder Tour noch Phase geladen sind, kurzen Spinner
// zeigen — das dauert in der Praxis maximal einen Frame.
if (phase == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return _buildForPhase(context, phase, carState.selectedCar.id);
},
);
},
);
},
);
}
Widget _buildForPhase(
BuildContext context,
DeliveryPhase phase,
int selectedCarId,
) {
switch (phase) {
case DeliveryPhase.auswaehlen:
// Auswahl-Page nur sichtbar bei Teams mit ≥2 Fahrzeugen — der
// PhaseBloc setzt diese Phase nicht für Ein-Auto-Teams.
return DeliverySelectionPage(selectedCarId: selectedCarId);
case DeliveryPhase.sortieren:
// Sort-Page baut eigenen Scaffold inkl. Stepper-Header.
return DeliverySortPage(selectedCarId: selectedCarId);
case DeliveryPhase.beladen:
// Beladen-Phase: Einstieg über die Übersicht. Der Fahrer wählt selbst
// aus, mit welchem Kunden er starten möchte — das Kunden-Vollbild
// wird per Tap auf eine Karte geöffnet (siehe LoadingOverviewPage).
return const LoadingOverviewPage();
case DeliveryPhase.ausliefern:
return const _DeliveryHome();
}
}
}
/// Klassisches Home für die Auslieferungs-Phase: BottomNavigationBar mit
/// drei Tabs (Auslieferung / Fahrzeuge / Einstellungen). Die Beladung als
/// Tab entfällt bewusst — wer in dieser Phase zurück zur Beladung möchte,
/// nutzt den Phasen-Stepper auf den jeweiligen Pages oder den Drawer.
class _DeliveryHome extends StatelessWidget {
const _DeliveryHome();
Widget _buildPage(int index) {
switch (index) {
case 0:
return const DeliveryOverviewPage();
case 1:
return const CarManagementPage();
case 2:
return const SettingsPage();
default:
return const SizedBox.shrink();
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<NavigationBloc, NavigationState>(
builder: (context, state) {
final currentState = state as NavigationInfo;
final navIndex = state is NavigationInfo ? state.navigationIndex : 0;
// Bei einem Tab-Index, der außerhalb des neuen Bereichs liegt
// (z. B. vom alten 4-Tab-Layout: 0..3), normieren wir defensiv auf 0.
final safeIndex = (navIndex >= 0 && navIndex <= 2) ? navIndex : 0;
return Scaffold(
body: _buildPage(currentState.navigationIndex),
body: _buildPage(safeIndex),
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
children: const [
SelectedCarBar(),
AppNavigationBar(),
],

View File

@ -0,0 +1,115 @@
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/events.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart';
import 'package:hl_lieferservice/feature/settings/presentation/settings_page.dart';
/// Globales Side-Menu für Fahrzeuge & Einstellungen.
///
/// Da Sortieren und Beladen kein BottomNav mehr haben, sind diese
/// administrativen Funktionen nur noch über den Drawer erreichbar.
/// Während der Auslieferungs-Phase steht zusätzlich das alte BottomNav
/// weiterhin zur Verfügung.
class HomeAppDrawer extends StatelessWidget {
const HomeAppDrawer({super.key});
void _openPage(BuildContext context, Widget page) {
Navigator.of(context).pop();
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => page),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final carState = context.watch<CarSelectBloc>().state;
return Drawer(
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DrawerHeader(
decoration: BoxDecoration(color: theme.primaryColor),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
"Holzleitner",
style: TextStyle(
color: theme.colorScheme.onSecondary,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
if (carState is CarSelectComplete)
Row(
children: [
Icon(
Icons.local_shipping,
color: theme.colorScheme.onSecondary,
size: 16,
),
const SizedBox(width: 6),
Text(
carState.selectedCar.plate,
style: TextStyle(
color: theme.colorScheme.onSecondary,
fontWeight: FontWeight.w600,
),
),
],
)
else
Text(
"Kein Fahrzeug ausgewählt",
style: TextStyle(
color: theme.colorScheme.onSecondary.withValues(
alpha: 0.8,
),
fontSize: 13,
),
),
],
),
),
if (carState is CarSelectComplete)
ListTile(
leading: const Icon(Icons.swap_horiz),
title: const Text("Fahrzeug wechseln"),
onTap: () {
Navigator.of(context).pop();
context.read<CarSelectBloc>().add(CarSelectChange());
},
),
ListTile(
leading: const Icon(Icons.local_shipping_outlined),
title: const Text("Fahrzeuge verwalten"),
onTap: () => _openPage(context, const CarManagementPage()),
),
ListTile(
leading: const Icon(Icons.settings_outlined),
title: const Text("Einstellungen"),
onTap: () => _openPage(context, const SettingsPage()),
),
const Spacer(),
const Divider(height: 1),
const Padding(
padding: EdgeInsets.all(12),
child: Text(
"Lieferservice-App",
style: TextStyle(fontSize: 11, color: Colors.grey),
),
),
],
),
),
);
}
}

View File

@ -4,47 +4,46 @@ import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_event.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_state.dart';
class AppNavigationBar extends StatefulWidget {
/// BottomNavigationBar des Home-Scaffolds — nur in der Auslieferungs-Phase
/// sichtbar (siehe `Home`). Die Tabs spiegeln die in dieser Phase relevanten
/// Bereiche wider: Auslieferung, Fahrzeugverwaltung, Einstellungen.
///
/// Beladung als Tab wurde bewusst entfernt: ist die App in der Auslieferung,
/// gehört die Beladung organisatorisch der Vergangenheit an. Wer dorthin
/// zurück muss, nutzt den Phasen-Stepper.
class AppNavigationBar extends StatelessWidget {
const AppNavigationBar({super.key});
@override
State<StatefulWidget> createState() => _AppNavigationBarState();
}
class _AppNavigationBarState extends State<AppNavigationBar> {
@override
Widget build(BuildContext context) {
return BlocBuilder<NavigationBloc, NavigationState>(
builder: (context, state) {
if (state is NavigationInfo) {
return NavigationBar(
selectedIndex: state.navigationIndex,
destinations: const [
NavigationDestination(
icon: Icon(Icons.barcode_reader),
label: "Beladung",
),
NavigationDestination(
icon: Icon(Icons.fire_truck),
label: "Auslieferung",
),
NavigationDestination(
icon: Icon(Icons.local_shipping),
label: "Fahrzeuge",
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: "Einstellungen",
),
],
onDestinationSelected: (int index) {
context.read<NavigationBloc>().add(NavigateToIndex(index: index));
},
);
}
if (state is! NavigationInfo) return const SizedBox.shrink();
return Container();
final navIndex = state.navigationIndex;
final safeIndex = (navIndex >= 0 && navIndex <= 2) ? navIndex : 0;
return NavigationBar(
selectedIndex: safeIndex,
destinations: const [
NavigationDestination(
icon: Icon(Icons.fire_truck),
label: "Auslieferung",
),
NavigationDestination(
icon: Icon(Icons.local_shipping),
label: "Fahrzeuge",
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: "Einstellungen",
),
],
onDestinationSelected: (int index) {
context.read<NavigationBloc>().add(NavigateToIndex(index: index));
},
);
},
);
}

View File

@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
/// Schmaler Info-Banner für die aktuelle Phase.
///
/// Wurde durch den [PhaseStepper] als primäre Navigations-Anzeige ersetzt,
/// bleibt aber als kompakte Alternative für Stellen verfügbar, an denen
/// kein vollwertiger Stepper sinnvoll ist (z. B. Dialoge, Sub-Pages).
///
/// Hinweis: Damit "Schritt X von Y" stimmt, MUSS dem Banner die sichtbare
/// Phasen-Liste mitgegeben werden — diese ist team-spezifisch (Ein- vs.
/// Mehrauto-Team) und kann nicht hartkodiert werden.
class PhaseBanner extends StatelessWidget {
const PhaseBanner({
super.key,
required this.phase,
required this.visiblePhases,
this.onAdvance,
this.onBack,
this.advanceLabel,
});
final DeliveryPhase phase;
/// Die für den aktuellen Fahrer relevanten Phasen — bestimmt die Werte
/// für "Schritt X von Y" und muss konsistent zum [PhaseStepper] sein.
final List<DeliveryPhase> visiblePhases;
final VoidCallback? onAdvance;
final VoidCallback? onBack;
/// Optionaler Text für den Vorwärts-Button. Default ist "Phase abschließen".
final String? advanceLabel;
Color _color(BuildContext context) {
return switch (phase) {
DeliveryPhase.auswaehlen => Colors.blueGrey.shade100,
DeliveryPhase.sortieren => Colors.indigo.shade100,
DeliveryPhase.beladen => Theme.of(context).colorScheme.primaryContainer,
DeliveryPhase.ausliefern => Colors.green.shade100,
};
}
Color _foreground(BuildContext context) {
return switch (phase) {
DeliveryPhase.auswaehlen => Colors.blueGrey.shade800,
DeliveryPhase.sortieren => Colors.indigo.shade800,
DeliveryPhase.beladen =>
Theme.of(context).colorScheme.onPrimaryContainer,
DeliveryPhase.ausliefern => Colors.green.shade800,
};
}
IconData _icon() {
return switch (phase) {
DeliveryPhase.auswaehlen => Icons.checklist_outlined,
DeliveryPhase.sortieren => Icons.reorder,
DeliveryPhase.beladen => Icons.inventory_2_outlined,
DeliveryPhase.ausliefern => Icons.local_shipping_outlined,
};
}
@override
Widget build(BuildContext context) {
final fg = _foreground(context);
return Material(
color: _color(context),
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
if (onBack != null)
IconButton(
onPressed: onBack,
icon: Icon(Icons.arrow_back, color: fg),
visualDensity: VisualDensity.compact,
tooltip: "Zurück",
)
else
const SizedBox(width: 8),
Icon(_icon(), color: fg, size: 20),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
phase.displayName,
style: TextStyle(
fontWeight: FontWeight.w700,
color: fg,
fontSize: 14,
),
),
Text(
"Schritt ${phase.stepNumberIn(visiblePhases)} von ${visiblePhases.length}",
style: TextStyle(
fontSize: 11,
color: fg.withValues(alpha: 0.8),
),
),
],
),
),
if (onAdvance != null)
TextButton(
onPressed: onAdvance,
style: TextButton.styleFrom(foregroundColor: fg),
child: Text(advanceLabel ?? "Phase abschließen"),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,357 @@
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/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_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';
/// Horizontaler Phasen-Stepper für die drei bzw. vier Schritte
/// (Auswählen) → Sortieren → Beladen → Ausliefern.
///
/// Ersetzt während aller Pre-Lieferungs-Phasen die [BottomNavigationBar]
/// und dient zugleich als globaler Header oberhalb der Phase-Inhalts-Page.
///
/// Sichtbare Phasen:
/// * Ein-Auto-Teams: Sortieren, Beladen, Ausliefern (3 Schritte).
/// * Mehr-Auto-Teams: Auswählen, Sortieren, Beladen, Ausliefern (4 Schritte).
///
/// Die Sichtbarkeitsliste wird intern aus dem [TourBloc] abgeleitet
/// (Anzahl `tour.driver.cars`). So müssen Aufrufer den Stepper nicht mit
/// Routing-Wissen versorgen — er bleibt eine reine Anzeige-Komponente,
/// die auf den globalen State reagiert.
///
/// Verhalten:
/// * Aktuelle Phase ist visuell hervorgehoben.
/// * Bereits besuchte Phasen (Phasen, die der Fahrer heute schon erreicht
/// hatte — verfolgt über `maxPhase` im [PhaseBloc]) lassen sich antippen,
/// auch wenn man aktuell auf einer früheren Phase steht. Damit kann der
/// Fahrer beliebig zwischen besuchten Schritten hin- und herspringen.
/// * Noch nicht besuchte Phasen sind sperren (SnackBar-Hinweis).
/// * Über das Menü-Icon links wird der Drawer geöffnet (Fahrzeuge/Settings).
/// * Rechts steht das Plate des aktiv gewählten Fahrzeugs.
class PhaseStepper extends StatelessWidget {
const PhaseStepper({
super.key,
required this.currentPhase,
required this.carId,
this.visiblePhases,
});
/// Die aktuell aktive Phase des Fahrzeugs.
final DeliveryPhase currentPhase;
/// Auto-ID, an der die Phase im [PhaseBloc] gespeichert wird.
final String carId;
/// Optionaler Override für Tests / Sonderfälle. Default: aus TourBloc
/// abgeleitet (Anzahl cars im Team).
final List<DeliveryPhase>? visiblePhases;
IconData _iconFor(DeliveryPhase phase) {
return switch (phase) {
DeliveryPhase.auswaehlen => Icons.checklist_outlined,
DeliveryPhase.sortieren => Icons.reorder,
DeliveryPhase.beladen => Icons.inventory_2_outlined,
DeliveryPhase.ausliefern => Icons.local_shipping_outlined,
};
}
/// Stellt sicher, dass auch die [currentPhase] in der angezeigten Liste
/// enthalten ist. Falls z. B. ein persistierter `auswaehlen`-State noch
/// aus einem Mehr-Auto-Team stammt, das Team aber inzwischen nur noch
/// ein Auto hat, würde die Phase sonst unsichtbar — wir nehmen sie dann
/// vorne mit auf, damit der Stepper konsistent bleibt.
List<DeliveryPhase> _effectivePhases(int carCount) {
final base = DeliveryPhaseExtension.visiblePhasesForCarCount(carCount);
if (base.contains(currentPhase)) return base;
return [currentPhase, ...base];
}
void _onTap(
BuildContext context,
DeliveryPhase target,
DeliveryPhase maxReached,
) {
// Vergleich über die natürliche Enum-Reihenfolge, weil "höchste erreichte
// Phase" cross-Team konsistent ist (1- vs. Mehr-Auto-Teams).
if (target.index > maxReached.index) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Schritt \"${target.displayName}\" ist noch nicht erreicht.",
),
duration: const Duration(seconds: 2),
),
);
return;
}
if (target == currentPhase) return;
// Vor dem Phasen-Wechsel alle gepushten Routen entfernen (z. B. die
// LoadingOverviewPage). Sonst rendert home.dart die neue Phase nur im
// Hintergrund, während die alte Page mit ihrem Scanner-State sichtbar
// bleibt — was zu einer toten Kamera-View führt.
Navigator.of(context).popUntil((route) => route.isFirst);
context.read<PhaseBloc>().add(
PhaseSet(carId: carId, phase: target),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final onPrimary = theme.colorScheme.onPrimary;
return BlocBuilder<TourBloc, TourState>(
// Stepper reagiert auf cars.length-Änderungen — sonst praktisch statisch.
buildWhen: (prev, curr) {
if (visiblePhases != null) return prev != curr; // override aktiv
final prevCars = prev is TourLoaded ? prev.tour.driver.cars.length : 0;
final currCars = curr is TourLoaded ? curr.tour.driver.cars.length : 0;
return prevCars != currCars || prev.runtimeType != curr.runtimeType;
},
builder: (context, tourState) {
final carCount = tourState is TourLoaded
? tourState.tour.driver.cars.length
: 0;
final phases = visiblePhases ?? _effectivePhases(carCount);
// Höchste erreichte Phase aus dem PhaseBloc — bestimmt, welche
// Vorwärts-Sprünge erlaubt sind.
final phaseBlocState = context.watch<PhaseBloc>().state;
final maxReached = phaseBlocState is PhaseReady
? (phaseBlocState.maxPhaseFor(carId) ?? currentPhase)
: currentPhase;
return Material(
color: theme.primaryColor,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 8, 30),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
// Menü-Button — öffnet den Drawer des umgebenden Scaffolds.
IconButton(
icon: Icon(Icons.menu, color: onPrimary),
tooltip: "Menü",
onPressed: () => Scaffold.of(context).openDrawer(),
),
const Spacer(),
BlocBuilder<CarSelectBloc, CarSelectState>(
builder: (context, state) {
if (state is! CarSelectComplete) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(right: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.local_shipping,
color: onPrimary,
size: 18,
),
const SizedBox(width: 6),
Text(
state.selectedCar.plate,
style: TextStyle(
color: onPrimary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
);
},
),
],
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
for (int i = 0; i < phases.length; i++) ...[
Expanded(
child: _StepperItem(
phase: phases[i],
currentPhase: currentPhase,
maxReached: maxReached,
visiblePhases: phases,
icon: _iconFor(phases[i]),
onTap: () =>
_onTap(context, phases[i], maxReached),
),
),
if (i < phases.length - 1)
_Connector(
// Verbindung gilt als "abgehakt", wenn die rechte
// Phase bereits besucht wurde.
isPassed:
phases[i + 1].index <= maxReached.index,
),
],
],
),
),
],
),
),
),
);
},
);
}
}
class _StepperItem extends StatelessWidget {
const _StepperItem({
required this.phase,
required this.currentPhase,
required this.maxReached,
required this.visiblePhases,
required this.icon,
required this.onTap,
});
final DeliveryPhase phase;
final DeliveryPhase currentPhase;
final DeliveryPhase maxReached;
final List<DeliveryPhase> visiblePhases;
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final onPrimary = theme.colorScheme.onPrimary;
final int myStep = phase.stepNumberIn(visiblePhases);
final bool isCurrent = phase == currentPhase;
// "Besucht" = irgendwann heute schon erreicht (über Enum-Index), aber
// nicht die aktuelle Phase. Erlaubt sowohl Rück- als auch Vorwärts-Tap.
final bool isPassed =
!isCurrent && phase.index <= maxReached.index;
final Color circleColor;
final Color iconColor;
final Color labelColor;
final FontWeight labelWeight;
if (isCurrent) {
circleColor = onPrimary;
iconColor = theme.primaryColor;
labelColor = onPrimary;
labelWeight = FontWeight.w700;
} else if (isPassed) {
circleColor = onPrimary.withValues(alpha: 0.85);
iconColor = theme.primaryColor;
labelColor = onPrimary;
labelWeight = FontWeight.w600;
} else {
circleColor = onPrimary.withValues(alpha: 0.25);
iconColor = onPrimary.withValues(alpha: 0.65);
labelColor = onPrimary.withValues(alpha: 0.65);
labelWeight = FontWeight.w500;
}
final child = Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
alignment: Alignment.center,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: circleColor,
shape: BoxShape.circle,
border: isCurrent
? Border.all(color: onPrimary, width: 2)
: null,
),
child: Icon(icon, color: iconColor, size: 18),
),
if (isPassed)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 13,
height: 13,
decoration: BoxDecoration(
color: Colors.green.shade600,
shape: BoxShape.circle,
border: Border.all(color: theme.primaryColor, width: 1.5),
),
child: const Icon(
Icons.check,
color: Colors.white,
size: 8,
),
),
),
],
),
const SizedBox(height: 3),
Text(
phase.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: labelColor,
fontSize: 12,
fontWeight: labelWeight,
),
),
],
);
return Semantics(
button: true,
selected: isCurrent,
label:
"${phase.displayName}, Schritt $myStep von ${visiblePhases.length}",
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: child,
),
),
);
}
}
class _Connector extends StatelessWidget {
const _Connector({required this.isPassed});
final bool isPassed;
@override
Widget build(BuildContext context) {
final onPrimary = Theme.of(context).colorScheme.onPrimary;
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Container(
width: 14,
height: 2,
color: isPassed ? onPrimary : onPrimary.withValues(alpha: 0.35),
),
);
}
}

View File

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
/// Einheitliche Visualisierung für "Artikel aus Außenlager".
///
/// Wir nutzen bewusst nur eine Farbe (Amber) — selbst bei 510 möglichen
/// Lagern bleibt die Karte mit einem zusätzlichen Lagernamen als Text
/// lesbar. Mehrere Lager-Farben hätten zu Konfusion geführt und einzelne
/// Lager nicht eindeutig zugeordnet.
///
/// [warehouseNames] ist optional: ohne Namen erscheint nur "Außenlager".
class WarehouseBadge extends StatelessWidget {
const WarehouseBadge({
super.key,
this.warehouseNames = const [],
this.compact = false,
});
/// Die Distinct-Liste der Lagernamen (kommt typischerweise aus
/// Delivery.distinctExternalWarehouseNames).
final List<String> warehouseNames;
/// Kompakte Darstellung für enge Bereiche wie Sortier- und Scan-Listen.
final bool compact;
@override
Widget build(BuildContext context) {
final fg = Colors.amber.shade700;
final bg = Colors.amber.shade100;
final label = _buildLabel();
final iconSize = compact ? 14.0 : 16.0;
final textStyle = TextStyle(
color: fg,
fontWeight: FontWeight.w600,
fontSize: compact ? 11 : 12,
);
return Semantics(
label: "Artikel aus $label",
child: Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : 8,
vertical: compact ? 2 : 4,
),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: fg.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.warehouse, size: iconSize, color: fg),
SizedBox(width: compact ? 4 : 6),
Flexible(
child: Text(
label,
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
String _buildLabel() {
if (warehouseNames.isEmpty) return "Außenlager";
if (warehouseNames.length == 1) return warehouseNames.first;
return warehouseNames.join(" + ");
}
}