Final commit.

This commit is contained in:
Dennis Nemec
2026-06-01 17:12:28 +02:00
parent 3ecbc82885
commit a9bf8ecdd1
385 changed files with 29081 additions and 12089 deletions

View File

@ -1,6 +1,9 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_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/tour_bloc.dart';
@ -8,8 +11,6 @@ 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_fail_page.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
@ -20,18 +21,11 @@ import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
/// * Tab "Verfügbar" — alle Lieferungen, die noch keinem Fahrzeug
/// zugeordnet sind. Multiselect, Bulk-Bestätigung per BottomBar-Button.
/// * Tab "Vergeben" — Lieferungen, die bereits zugeordnet sind. Tap auf
/// eigene Lieferung → Freigabe-Dialog; Tap auf fremde → Umlade-Dialog.
/// * Persistent untere BottomBar: "Weiter zum Sortieren" — wechselt die
/// Phase. Es gibt bewusst keinen Zwang, dass alle Lieferungen verteilt
/// sein müssen; der Fahrer entscheidet wann er weiterzieht.
/// eigene → Freigabe-Dialog; Tap auf fremde → Umlade-Dialog.
/// * Persistente BottomBar: "Weiter zum Sortieren" — wechselt die Phase.
class DeliverySelectionPage extends StatefulWidget {
const DeliverySelectionPage({
super.key,
required this.selectedCarId,
});
const DeliverySelectionPage({super.key, required this.selectedCarId});
/// ID des aktuell gewählten Fahrzeugs (Eigene Lieferungen / Ziel von
/// Übernahmen).
final String selectedCarId;
@override
@ -39,86 +33,63 @@ class DeliverySelectionPage extends StatefulWidget {
}
class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
/// Lokale Multi-Selektion im Tab "Verfügbar". Wird nach erfolgreichem
/// Bulk-Assign geleert.
final Set<String> _selectedIds = <String>{};
/// True während sequentieller Assign-Calls — schaltet den
/// Bestätigungs-Button auf Spinner und disabled.
bool _isAssigning = false;
String get _carIdString => widget.selectedCarId.toString();
/// Sucht das Plate eines Autos in der Tour-Driver-Liste. Liefert "?" als
/// Fallback, falls die Zuordnung nicht (mehr) im Team enthalten ist —
/// z. B. nach Personalwechsel zwischen Tour-Synchronisationen.
String _plateFor(String? carId, Tour tour) {
if (carId == null) return "?";
final car = tour.driver.cars.firstWhereOrNull((c) => c.id == carId);
return car?.plate ?? "?";
/// Sucht das Kennzeichen eines Fahrzeugs in der aktuell geladenen
/// CarsBloc-Liste. Liefert "?" als Fallback, wenn das Auto nicht (mehr)
/// im Account ist — z. B. nach Personalwechsel zwischen Tour-Syncs.
String _plateFor(String? carId) {
if (carId == null) return '?';
final carsState = context.read<CarsBloc>().state;
if (carsState is! CarsLoaded) return '?';
for (final c in carsState.cars) {
if (c.id == carId) return c.plate;
}
return '?';
}
/// Bulk-Assign der aktuell selektierten Lieferungen an das eigene Auto.
/// Sequentiell, damit der lokale Tour-Stream nach jedem Schritt
/// konsistent ist und die Listen-Filter live mitwandern. Bei Fehlern
/// wird eine SnackBar gezeigt und die Selektion bleibt erhalten.
Future<void> _confirmSelection() async {
if (_selectedIds.isEmpty || _isAssigning) return;
setState(() => _isAssigning = true);
final tourBloc = context.read<TourBloc>();
void _confirmSelection() {
if (_selectedIds.isEmpty) return;
final ids = List<String>.from(_selectedIds);
try {
for (final id in ids) {
tourBloc.add(
AssignCarEvent(deliveryId: id, carId: _carIdString),
);
}
// Hinweis: TourBloc verarbeitet die Events asynchron; lokale
// Tour-Updates erfolgen über den Stream. Wir leeren die Selektion
// optimistisch, damit der Fahrer ein klares Feedback bekommt.
if (!mounted) return;
setState(_selectedIds.clear);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Fehler beim Übernehmen: $e")),
);
} finally {
if (mounted) setState(() => _isAssigning = false);
}
}
/// Wechselt in die nächste Phase (Sortieren). Der Fahrer kann jederzeit
/// zurückspringen — die persistierte Phase wird über den Stepper-Tap
/// zurückgesetzt.
void _goToSorting() {
context.read<PhaseBloc>().add(
PhaseSet(
carId: _carIdString,
phase: DeliveryPhase.sortieren,
// EIN Bulk-Event statt N parallel laufender Single-Events: vermeidet
// die Race-Condition, bei der `flutter_bloc`s default-concurrent
// Event-Processing parallele Handler den Initial-State lesen lässt
// und sich am Ende beim `emit` gegenseitig überschreiben.
context.read<TourBloc>().add(
AssignCarToDeliveries(
deliveryIds: ids,
carId: widget.selectedCarId,
),
);
setState(_selectedIds.clear);
}
Future<void> _showReleaseDialog(Delivery delivery) async {
void _goToSorting() {
context.read<PhaseBloc>().add(
PhaseSet(carId: widget.selectedCarId, phase: DeliveryPhase.sortieren),
);
}
Future<void> _showReleaseDialog(Delivery delivery, TourDetails details) async {
final customer = details.customerOf(delivery);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Lieferung freigeben"),
title: const Text('Lieferung freigeben'),
content: Text(
"${delivery.customer.name} wurde Ihrem Fahrzeug zugeordnet. "
"Möchten Sie diese Lieferung wieder freigeben?",
'${customer?.name ?? 'Diese Lieferung'} wurde Ihrem Fahrzeug '
'zugeordnet. Möchten Sie sie wieder freigeben?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text("Abbrechen"),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text("Freigeben"),
child: const Text('Freigeben'),
),
],
),
@ -126,54 +97,57 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
if (result != true || !mounted) return;
context.read<TourBloc>().add(
UnassignDeliveryEvent(deliveryId: delivery.id),
AssignCarToDelivery(deliveryId: delivery.id, carId: null),
);
}
Future<void> _showTakeoverDialog(Delivery delivery, Tour tour) async {
final foreignPlate = _plateFor(delivery.carId, tour);
final ownPlate = _plateFor(widget.selectedCarId, tour);
Future<void> _showTakeoverDialog(
Delivery delivery,
TourDetails details,
) async {
final customer = details.customerOf(delivery);
final foreignPlate = _plateFor(delivery.assignedCarId);
final ownPlate = _plateFor(widget.selectedCarId);
final theme = Theme.of(context);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Lieferung umladen"),
title: const Text('Lieferung umladen'),
content: RichText(
text: TextSpan(
style: theme.textTheme.bodyMedium,
children: [
TextSpan(text: "${delivery.customer.name} ist aktuell "),
TextSpan(
text: '${customer?.name ?? 'Diese Lieferung'} ist aktuell ',
),
TextSpan(
text: foreignPlate,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(text: " zugeordnet. Möchten Sie diese "
"Lieferung auf "),
const TextSpan(
text: ' zugeordnet. Möchten Sie diese Lieferung auf ',
),
TextSpan(
text: ownPlate,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(text: " umladen?"),
const TextSpan(text: ' umladen?'),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text("Abbrechen"),
child: const Text('Abbrechen'),
),
FilledButton(
// Warnfarbe, da das Umladen eine bestehende Zuordnung
// überschreibt — last write wins. `colorScheme.error` ist
// bewusst hart gewählt, damit der Fahrer den Eingriff in
// fremde Disposition bewusst bestätigt.
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.error,
foregroundColor: theme.colorScheme.onError,
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text("Übernehmen"),
child: const Text('Übernehmen'),
),
],
),
@ -181,16 +155,14 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
if (result != true || !mounted) return;
context.read<TourBloc>().add(
AssignCarEvent(
AssignCarToDelivery(
deliveryId: delivery.id,
carId: _carIdString,
carId: widget.selectedCarId,
),
);
}
// ---------------------------------------------------------------------------
// Widgets
// ---------------------------------------------------------------------------
// ─── Widgets ─────────────────────────────────────────────────────────
Widget _plateBadge(BuildContext context, String plate, {bool own = false}) {
final theme = Theme.of(context);
@ -213,11 +185,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
const SizedBox(width: 4),
Text(
plate,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: fg,
),
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: fg),
),
],
),
@ -257,13 +225,13 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
);
}
Widget _availableTab(List<Delivery> available) {
Widget _availableTab(List<Delivery> available, TourDetails details) {
if (available.isEmpty) {
return _emptyState(
icon: Icons.inbox_outlined,
title: "Alle Lieferungen sind verteilt.",
subtitle: "Im Tab \"Vergeben\" können Sie eigene Lieferungen "
"freigeben oder fremde übernehmen.",
title: 'Alle Lieferungen sind verteilt.',
subtitle: 'Im Tab "Vergeben" können Sie eigene Lieferungen '
'freigeben oder fremde übernehmen.',
);
}
@ -272,27 +240,29 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
itemCount: available.length,
itemBuilder: (context, index) {
final delivery = available[index];
final customer = details.customerOf(delivery);
final isSelected = _selectedIds.contains(delivery.id);
return CheckboxListTile(
key: ValueKey("available-${delivery.id}"),
key: ValueKey('available-${delivery.id}'),
value: isSelected,
onChanged: _isAssigning
? null
: (checked) {
setState(() {
if (checked == true) {
_selectedIds.add(delivery.id);
} else {
_selectedIds.remove(delivery.id);
}
});
},
onChanged: (checked) {
// Während ein Bulk-Zuweisung läuft, blockiert der
// OperationViewEnforcer die Eingabe global; ein separates
// `_isAssigning`-Lock ist hier nicht mehr nötig.
setState(() {
if (checked == true) {
_selectedIds.add(delivery.id);
} else {
_selectedIds.remove(delivery.id);
}
});
},
title: Text(
delivery.customer.name,
customer?.name ?? '⟨Unbekannter Kunde⟩',
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
delivery.customer.address.toString(),
delivery.deliveryAddressSnapshot.oneLine,
style: const TextStyle(fontSize: 12),
),
controlAffinity: ListTileControlAffinity.leading,
@ -301,11 +271,11 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
);
}
Widget _assignedTab(List<Delivery> assigned, Tour tour) {
Widget _assignedTab(List<Delivery> assigned, TourDetails details) {
if (assigned.isEmpty) {
return _emptyState(
icon: Icons.local_shipping_outlined,
title: "Noch keine Lieferungen verteilt.",
title: 'Noch keine Lieferungen verteilt.',
);
}
@ -316,20 +286,21 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
itemCount: assigned.length,
itemBuilder: (context, index) {
final delivery = assigned[index];
final isOwn = delivery.carId == widget.selectedCarId;
final plate = _plateFor(delivery.carId, tour);
final isOwn = delivery.assignedCarId == widget.selectedCarId;
final plate = _plateFor(delivery.assignedCarId);
final customer = details.customerOf(delivery);
return Material(
color: isOwn
? theme.colorScheme.primaryContainer.withValues(alpha: 0.35)
: null,
child: ListTile(
key: ValueKey("assigned-${delivery.id}"),
key: ValueKey('assigned-${delivery.id}'),
onTap: () {
if (isOwn) {
_showReleaseDialog(delivery);
_showReleaseDialog(delivery, details);
} else {
_showTakeoverDialog(delivery, tour);
_showTakeoverDialog(delivery, details);
}
},
leading: Icon(
@ -339,14 +310,14 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
: theme.colorScheme.onSurfaceVariant,
),
title: Text(
delivery.customer.name,
customer?.name ?? '⟨Unbekannter Kunde⟩',
style: TextStyle(
fontWeight: FontWeight.w600,
color: isOwn ? theme.colorScheme.primary : null,
),
),
subtitle: Text(
delivery.customer.address.toString(),
delivery.deliveryAddressSnapshot.oneLine,
style: const TextStyle(fontSize: 12),
),
trailing: _plateBadge(context, plate, own: isOwn),
@ -356,13 +327,12 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
);
}
/// BottomBar mit Bulk-Confirm (kontextabhängig) + Phasen-Wechsel.
/// Confirm-Button ist nur sichtbar, wenn etwas selektiert ist —
/// "Weiter zum Sortieren" bleibt immer sichtbar.
Widget _buildBottomBar() {
final theme = Theme.of(context);
final hasSelection = _selectedIds.isNotEmpty;
// Disabled-State und Spinner während des Bulk-Zuweisens übernimmt
// global der `OperationViewEnforcer` (StartOperation/FinishOperation
// aus dem TourBloc) — daher hier keine lokale Lock-Variable mehr.
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
@ -373,20 +343,10 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed:
_isAssigning ? null : _confirmSelection,
icon: _isAssigning
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: const Icon(Icons.check),
onPressed: _confirmSelection,
icon: const Icon(Icons.check),
label: Text(
"Auswahl bestätigen (${_selectedIds.length})",
'Auswahl bestätigen (${_selectedIds.length})',
),
),
),
@ -394,9 +354,9 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isAssigning ? null : _goToSorting,
onPressed: _goToSorting,
icon: const Icon(Icons.arrow_forward),
label: const Text("Weiter zum Sortieren"),
label: const Text('Weiter zum Sortieren'),
),
),
],
@ -409,24 +369,38 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is TourLoadingFailed) {
if (state is TourLoadFailed) {
return const DeliveryLoadingFailedPage();
}
if (state is TourEmpty) {
return Scaffold(
appBar: AppBar(title: const Text('Lieferungen auswählen')),
body: const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Für heute ist keine Tour zugewiesen.',
style: TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
),
),
);
}
if (state is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final available = state.tour.deliveries
.where((d) => d.carId == null)
final details = state.details;
final available = details.deliveries
.where((d) => d.assignedCarId == null)
.toList();
final assigned = state.tour.deliveries
.where((d) => d.carId != null)
final assigned = details.deliveries
.where((d) => d.assignedCarId != null)
.toList();
// Falls eine selektierte Lieferung in der Zwischenzeit zugeordnet
// wurde (z. B. durch einen parallelen Vorgang), Selektion bereinigen.
_selectedIds.removeWhere(
(id) => !available.any((d) => d.id == id),
);
@ -442,22 +416,20 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
children: [
PhaseStepper(
currentPhase: DeliveryPhase.auswaehlen,
carId: _carIdString,
carId: widget.selectedCarId,
),
Material(
color: Theme.of(context).primaryColor,
child: TabBar(
labelColor:
Theme.of(context).colorScheme.onPrimary,
labelColor: Theme.of(context).colorScheme.onPrimary,
unselectedLabelColor: Theme.of(context)
.colorScheme
.onPrimary
.withValues(alpha: 0.6),
indicatorColor:
Theme.of(context).colorScheme.onPrimary,
indicatorColor: Theme.of(context).colorScheme.onPrimary,
tabs: [
Tab(text: "Verfügbar (${available.length})"),
Tab(text: "Vergeben (${assigned.length})"),
Tab(text: 'Verfügbar (${available.length})'),
Tab(text: 'Vergeben (${assigned.length})'),
],
),
),
@ -466,8 +438,8 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
),
body: TabBarView(
children: [
_availableTab(available),
_assignedTab(assigned, state.tour),
_availableTab(available, details),
_assignedTab(assigned, details),
],
),
bottomNavigationBar: _buildBottomBar(),