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.
480 lines
16 KiB
Dart
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(),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|