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:
@ -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
|
||||
|
||||
@ -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(),
|
||||
],
|
||||
|
||||
115
lib/widget/home/presentation/home_drawer.dart
Normal file
115
lib/widget/home/presentation/home_drawer.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
121
lib/widget/phase_banner.dart
Normal file
121
lib/widget/phase_banner.dart
Normal 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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
357
lib/widget/phase_stepper/phase_stepper.dart
Normal file
357
lib/widget/phase_stepper/phase_stepper.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
73
lib/widget/warehouse_badge.dart
Normal file
73
lib/widget/warehouse_badge.dart
Normal 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 5–10 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(" + ");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user