Files
Holzleitner-Lieferservice-App/lib/feature/delivery/overview/presentation/delivery_selection_page.dart
Dennis Nemec 3ecbc82885 Phase C+D-1: Cars-Domain auf Rust-Backend umgestellt
Clean-Arch-Schichten für Cars:
- lib/domain/entity/car.dart: UUID-id, accountId (Personalnummer),
  plate, active. Pendant zum Backend-Schema.
- lib/domain/repository/cars_repository.dart: Port — listMine,
  create, update. Keine teamId/personalnummer-Parameter, der
  Account fließt serverseitig aus dem JWT.
- lib/data/mapper/car_mapper.dart: API-DTO (built_value) → Domain.
- lib/data/repository/cars_repository_impl.dart: konkrete Impl via
  generierter CarsApi (dio), mit DioException → CarsRepositoryException-
  Übersetzung.

Feature-Cars-Refactoring:
- CarsBloc nimmt jetzt die Domain-Repository-Schnittstelle. Events:
  CarLoad/CarAdd/CarEdit/CarDeactivate (statt CarDelete). Keine
  teamId-Parameter mehr. Kein authBloc-Bezug, Session-Expiry läuft
  über den globalen Provider-Stream.
- CarsState sealed mit CarsInitial/Loading/LoadingFailed/Loaded.
- Pages: car_management_page, car_management, car_card, car_fail_page,
  car_selection_page komplett auf die neue Entity und Event-Signaturen.
- Alte lib/feature/cars/service/cars_service.dart und
  lib/feature/cars/repository/cars_repository.dart gelöscht.

CarSelectBloc + Storage:
- CarSelection.selectedCarId von int? auf String? umgestellt.
- CarSelectionRepository persistiert die UUID jetzt als String;
  defensive Migration für noch vorhandene int-Werte (alte
  Pre-Migration-Installations) verwirft den Wert leise und
  erzwingt Neuauswahl.

Konsequenz-Cleanup im Tour-Code (Phase-D-Vorbereitung):
- Delivery.carId String? statt int?.
- Tour.hasUndeliveredLoadedArticles / getFinishedDeliveries auf
  String carId.
- _selectedCarId / int? carId / int selectedCarId in DeliveryOverview,
  LoadingCustomerPage/OverviewPage, Home, DeliverySelection/SortPage,
  DeliveryInfo/List, CustomSortDialog, SortableDeliveryList auf
  String umgestellt.
- TourRepository ersetzt int.parse(carId)/int.tryParse-Zuweisungen
  direkt durch String.
- lib/model/car.dart wird zum Re-Export der neuen Domain-Entity,
  damit Legacy-Imports während Phase-D-Übergang weiter compilieren.

DI:
- app.dart: CarsBloc bekommt CarsRepositoryImpl(locator<HolzleitnerApi>())
  statt der alten CarsRepository(service: CarService()).

Build (flutter build apk --debug) durch, flutter analyze ohne
errors.
2026-05-15 11:55:24 +02:00

480 lines
16 KiB
Dart

import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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';
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';
/// Page für die erste Phase bei Mehr-Auto-Teams: Auswählen der eigenen
/// Lieferungen aus dem gemeinsamen Tour-Pool.
///
/// Aufbau:
/// * 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.
class DeliverySelectionPage extends StatefulWidget {
const DeliverySelectionPage({
super.key,
required this.selectedCarId,
});
/// ID des aktuell gewählten Fahrzeugs (Eigene Lieferungen / Ziel von
/// Übernahmen).
final String selectedCarId;
@override
State<StatefulWidget> createState() => _DeliverySelectionPageState();
}
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 ?? "?";
}
/// 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>();
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,
),
);
}
Future<void> _showReleaseDialog(Delivery delivery) async {
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Lieferung freigeben"),
content: Text(
"${delivery.customer.name} wurde Ihrem Fahrzeug zugeordnet. "
"Möchten Sie diese Lieferung wieder freigeben?",
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text("Abbrechen"),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text("Freigeben"),
),
],
),
);
if (result != true || !mounted) return;
context.read<TourBloc>().add(
UnassignDeliveryEvent(deliveryId: delivery.id),
);
}
Future<void> _showTakeoverDialog(Delivery delivery, Tour tour) async {
final foreignPlate = _plateFor(delivery.carId, tour);
final ownPlate = _plateFor(widget.selectedCarId, tour);
final theme = Theme.of(context);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Lieferung umladen"),
content: RichText(
text: TextSpan(
style: theme.textTheme.bodyMedium,
children: [
TextSpan(text: "${delivery.customer.name} ist aktuell "),
TextSpan(
text: foreignPlate,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(text: " zugeordnet. Möchten Sie diese "
"Lieferung auf "),
TextSpan(
text: ownPlate,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(text: " umladen?"),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
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"),
),
],
),
);
if (result != true || !mounted) return;
context.read<TourBloc>().add(
AssignCarEvent(
deliveryId: delivery.id,
carId: _carIdString,
),
);
}
// ---------------------------------------------------------------------------
// Widgets
// ---------------------------------------------------------------------------
Widget _plateBadge(BuildContext context, String plate, {bool own = false}) {
final theme = Theme.of(context);
final bg = own
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest;
final fg = own
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.local_shipping_outlined, size: 12, color: fg),
const SizedBox(width: 4),
Text(
plate,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: fg,
),
),
],
),
);
}
Widget _emptyState({
required IconData icon,
required String title,
String? subtitle,
}) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 64, color: theme.colorScheme.outline),
const SizedBox(height: 12),
Text(
title,
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(
subtitle,
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
],
),
),
);
}
Widget _availableTab(List<Delivery> available) {
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.",
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: available.length,
itemBuilder: (context, index) {
final delivery = available[index];
final isSelected = _selectedIds.contains(delivery.id);
return CheckboxListTile(
key: ValueKey("available-${delivery.id}"),
value: isSelected,
onChanged: _isAssigning
? null
: (checked) {
setState(() {
if (checked == true) {
_selectedIds.add(delivery.id);
} else {
_selectedIds.remove(delivery.id);
}
});
},
title: Text(
delivery.customer.name,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
delivery.customer.address.toString(),
style: const TextStyle(fontSize: 12),
),
controlAffinity: ListTileControlAffinity.leading,
);
},
);
}
Widget _assignedTab(List<Delivery> assigned, Tour tour) {
if (assigned.isEmpty) {
return _emptyState(
icon: Icons.local_shipping_outlined,
title: "Noch keine Lieferungen verteilt.",
);
}
final theme = Theme.of(context);
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: assigned.length,
itemBuilder: (context, index) {
final delivery = assigned[index];
final isOwn = delivery.carId == widget.selectedCarId;
final plate = _plateFor(delivery.carId, tour);
return Material(
color: isOwn
? theme.colorScheme.primaryContainer.withValues(alpha: 0.35)
: null,
child: ListTile(
key: ValueKey("assigned-${delivery.id}"),
onTap: () {
if (isOwn) {
_showReleaseDialog(delivery);
} else {
_showTakeoverDialog(delivery, tour);
}
},
leading: Icon(
isOwn ? Icons.check_circle : Icons.person_outline,
color: isOwn
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
title: Text(
delivery.customer.name,
style: TextStyle(
fontWeight: FontWeight.w600,
color: isOwn ? theme.colorScheme.primary : null,
),
),
subtitle: Text(
delivery.customer.address.toString(),
style: const TextStyle(fontSize: 12),
),
trailing: _plateBadge(context, plate, own: isOwn),
),
);
},
);
}
/// 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;
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (hasSelection)
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),
label: Text(
"Auswahl bestätigen (${_selectedIds.length})",
),
),
),
if (hasSelection) const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isAssigning ? null : _goToSorting,
icon: const Icon(Icons.arrow_forward),
label: const Text("Weiter zum Sortieren"),
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is TourLoadingFailed) {
return const DeliveryLoadingFailedPage();
}
if (state is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final available = state.tour.deliveries
.where((d) => d.carId == null)
.toList();
final assigned = state.tour.deliveries
.where((d) => d.carId != 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),
);
return DefaultTabController(
length: 2,
child: Scaffold(
drawer: const HomeAppDrawer(),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(140 + kTextTabBarHeight),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
PhaseStepper(
currentPhase: DeliveryPhase.auswaehlen,
carId: _carIdString,
),
Material(
color: Theme.of(context).primaryColor,
child: TabBar(
labelColor:
Theme.of(context).colorScheme.onPrimary,
unselectedLabelColor: Theme.of(context)
.colorScheme
.onPrimary
.withValues(alpha: 0.6),
indicatorColor:
Theme.of(context).colorScheme.onPrimary,
tabs: [
Tab(text: "Verfügbar (${available.length})"),
Tab(text: "Vergeben (${assigned.length})"),
],
),
),
],
),
),
body: TabBarView(
children: [
_availableTab(available),
_assignedTab(assigned, state.tour),
],
),
bottomNavigationBar: _buildBottomBar(),
),
);
},
);
}
}