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,56 +0,0 @@
import 'package:flutter/cupertino.dart';
class SortingInformation {
String deliveryId;
int position;
SortingInformation({required this.deliveryId, required this.position});
static Map<String, dynamic> toJson(SortingInformation info) {
return {"delivery_id": info.deliveryId, "position": info.position};
}
static SortingInformation fromJson(Map<String, dynamic> json) {
return SortingInformation(
deliveryId: json["delivery_id"].toString(),
position: json["position"],
);
}
}
class SortingInformationContainer {
Map<String, List<String>> cars;
SortingInformationContainer({required this.cars});
static SortingInformationContainer fromJson(Map<String, dynamic> json) {
SortingInformationContainer container = SortingInformationContainer(
cars: {},
);
for (final car in json["cars"].entries) {
List<String> values = [];
for (String value in car.value) {
values.add(value);
}
container.cars[car.key] = values;
}
return container;
}
Map<String, dynamic> toJson() {
Map<String, dynamic> cars = {};
for (final car in this.cars.entries) {
cars[car.key] = car.value;
}
return {"cars": cars};
}
SortingInformationContainer copyWith({Map<String, List<String>>? sorting}) {
return SortingInformationContainer(cars: sorting ?? cars);
}
}

View File

@ -1,16 +1,17 @@
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/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
/// Fallback-Page, die der Übersichts- und Beladen-Pfad anzeigt, wenn der
/// Initial-Tour-Load gescheitert ist. Tap auf "Erneut versuchen" feuert
/// `LoadTour` erneut — Account-Filter sitzt jetzt im JWT, daher keine
/// Personalnummer mehr nötig.
class DeliveryLoadingFailedPage extends StatelessWidget {
const DeliveryLoadingFailedPage({super.key});
void _onRetry(BuildContext context) {
Authenticated state = context.read<AuthBloc>().state as Authenticated;
context.read<TourBloc>().add(LoadTour(teamId: state.user.number));
context.read<TourBloc>().add(const LoadTour());
}
@override
@ -21,18 +22,22 @@ class DeliveryLoadingFailedPage extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 72, color: Theme.of(context).colorScheme.error,),
Padding(
padding: const EdgeInsets.only(top: 30),
Icon(
Icons.error_outline,
size: 72,
color: Theme.of(context).colorScheme.error,
),
const Padding(
padding: EdgeInsets.only(top: 30),
child: Text(
"Leider ist es beim Laden der Fahrten zu einem Fehler gekommen.",
'Leider ist es beim Laden der Fahrten zu einem Fehler gekommen.',
),
),
Padding(
padding: const EdgeInsets.only(top: 30),
child: FilledButton(
onPressed: () => _onRetry(context),
child: Text("Erneut versuchen"),
child: const Text('Erneut versuchen'),
),
),
],

View File

@ -1,23 +1,28 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/model/delivery.dart' show DeliveryState;
import 'package:hl_lieferservice/model/tour.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:intl/intl.dart';
/// Kopf-Karte der Auslieferungs-Übersicht. Zeigt Datum, Anzahl Lieferungen
/// und Fortschrittsbalken — gefiltert auf das aktuell gewählte Fahrzeug,
/// damit der Fahrer seine eigene Tagesleistung sieht.
class DeliveryInfo extends StatelessWidget {
final Tour tour;
final TourDetails details;
final String? selectedCarId;
const DeliveryInfo({super.key, required this.tour, this.selectedCarId});
const DeliveryInfo({super.key, required this.details, this.selectedCarId});
@override
Widget build(BuildContext context) {
final String date = DateFormat("dd.MM.yyyy").format(tour.date);
final date = DateFormat('dd.MM.yyyy').format(details.tour.date);
final relevantDeliveries = selectedCarId != null
? tour.deliveries.where((d) => d.carId == selectedCarId).toList()
: tour.deliveries;
? details.deliveries
.where((d) => d.assignedCarId == selectedCarId)
.toList()
: details.deliveries;
final total = relevantDeliveries.length;
final done = relevantDeliveries
.where((d) => d.state == DeliveryState.finished)
.where((d) => d.state == DeliveryState.completed)
.length;
final progress = total > 0 ? done / total : 0.0;
final allDone = total > 0 && done == total;
@ -37,11 +42,11 @@ class DeliveryInfo extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.calendar_month),
const Padding(
children: const [
Icon(Icons.calendar_month),
Padding(
padding: EdgeInsets.only(left: 5),
child: Text("Datum"),
child: Text('Datum'),
),
],
),
@ -53,15 +58,15 @@ class DeliveryInfo extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.local_shipping_outlined),
const Padding(
children: const [
Icon(Icons.local_shipping_outlined),
Padding(
padding: EdgeInsets.only(left: 5),
child: Text("Lieferungen"),
child: Text('Lieferungen'),
),
],
),
Text("$done / $total"),
Text('$done / $total'),
],
),
const SizedBox(height: 10),

View File

@ -1,153 +0,0 @@
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/model/delivery.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart';
import '../../../../widget/operations/bloc/operation_bloc.dart';
import '../../detail/bloc/note_bloc.dart';
import '../../detail/repository/note_repository.dart';
import '../../detail/service/notes_service.dart';
class DeliveryListItem extends StatelessWidget {
final Delivery delivery;
final double? distance;
const DeliveryListItem({
super.key,
required this.delivery,
this.distance,
});
void _goToDelivery(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => NoteBloc(
deliveryId: delivery.id,
opBloc: context.read<OperationBloc>(),
authBloc: context.read<AuthBloc>(),
repository: NoteRepository(service: NoteService()),
),
child: DeliveryDetail(deliveryId: delivery.id),
),
),
);
}
(Color, Color, IconData, String) _stateStyle(BuildContext context) {
switch (delivery.state) {
case DeliveryState.finished:
return (
Colors.green.withValues(alpha: 0.07),
Colors.green.withValues(alpha: 0.35),
Icons.check_circle_rounded,
"Abgeschlossen",
);
case DeliveryState.canceled:
return (
Colors.red.withValues(alpha: 0.07),
Colors.red.withValues(alpha: 0.35),
Icons.cancel_rounded,
"Storniert",
);
case DeliveryState.onhold:
return (
Colors.orange.withValues(alpha: 0.07),
Colors.orange.withValues(alpha: 0.35),
Icons.pause_circle_rounded,
"Pausiert",
);
case DeliveryState.ongoing:
final distanceLabel = distance != null && !distance!.isNaN
? "${distance!.toStringAsFixed(1)} km"
: "";
return (
Theme.of(context).colorScheme.surfaceContainerLow,
Colors.transparent,
Icons.local_shipping_outlined,
distanceLabel,
);
}
}
@override
Widget build(BuildContext context) {
final (cardColor, borderColor, icon, statusLabel) = _stateStyle(context);
final isOngoing = delivery.state == DeliveryState.ongoing;
final iconColor = switch (delivery.state) {
DeliveryState.finished => Colors.green,
DeliveryState.canceled => Colors.red,
DeliveryState.onhold => Colors.orange,
DeliveryState.ongoing => Theme.of(context).primaryColor,
};
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: borderColor),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _goToDelivery(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(icon, color: iconColor, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
delivery.customer.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: isOngoing ? null : iconColor,
),
),
const SizedBox(height: 2),
Text(
delivery.customer.address.toString(),
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
statusLabel,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isOngoing
? Theme.of(context).colorScheme.onSurfaceVariant
: iconColor,
),
),
const SizedBox(height: 4),
Icon(
Icons.arrow_forward_ios,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
],
),
),
),
);
}
}

View File

@ -1,126 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_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/overview/presentation/delivery_overview.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'delivery_item.dart';
class DeliveryList extends StatefulWidget {
final String? selectedCarId;
final SortType sortType;
const DeliveryList({super.key, this.selectedCarId, required this.sortType});
@override
State<StatefulWidget> createState() => _DeliveryListState();
}
class _DeliveryListState extends State<DeliveryList> {
@override
void initState() {
super.initState();
}
Widget _showCustomSortedList(
List<Delivery> deliveries,
List<String> sortingInformation,
Map<String, double> distances,
) {
return ListView.separated(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
separatorBuilder: (context, index) => const Divider(height: 0),
itemBuilder: (context, index) {
String id = sortingInformation[index];
Delivery delivery = deliveries.firstWhere(
(delivery) =>
id == delivery.id &&
delivery.carId == widget.selectedCarId,
);
return DeliveryListItem(
delivery: delivery,
distance: distances[delivery.id],
);
},
itemCount: sortingInformation.length,
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
final currentState = state;
if (currentState is TourLoaded) {
if (widget.sortType == SortType.custom) {
return _showCustomSortedList(
currentState.tour.deliveries,
currentState.sortingInformation[widget.selectedCarId.toString()] ?? [],
currentState.distances ?? {},
);
}
final allDeliveries = currentState.tour.deliveries
.where((d) => d.carId == widget.selectedCarId)
.toList();
if (allDeliveries.isEmpty) {
return ListView(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
children: const [
Center(child: Text("Keine Auslieferungen gefunden")),
],
);
}
final ongoing = allDeliveries
.where((d) => d.state == DeliveryState.ongoing)
.toList();
final nonOngoing = allDeliveries
.where((d) => d.state != DeliveryState.ongoing)
.toList();
int Function(Delivery, Delivery) comparator;
switch (widget.sortType) {
case SortType.nameAsc:
comparator = (a, b) => a.customer.name.compareTo(b.customer.name);
break;
case SortType.nameDesc:
comparator = (a, b) => b.customer.name.compareTo(a.customer.name);
break;
case SortType.distance:
comparator = (a, b) =>
(currentState.distances?[a.id] ?? 0.0)
.compareTo(currentState.distances?[b.id] ?? 0.0);
break;
default:
comparator = (a, b) => a.customer.name.compareTo(b.customer.name);
}
ongoing.sort(comparator);
nonOngoing.sort(comparator);
final sorted = [...ongoing, ...nonOngoing];
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 8),
itemCount: sorted.length,
itemBuilder: (context, index) => DeliveryListItem(
delivery: sorted[index],
distance: currentState.distances?[sorted[index].id],
),
);
}
return Center(child: CircularProgressIndicator());
},
);
}
}

View File

@ -1,151 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/util.dart';
import 'package:hl_lieferservice/model/delivery.dart';
class CustomSortDialog extends StatefulWidget {
const CustomSortDialog({super.key, this.selectedCarId});
final String? selectedCarId;
@override
State<StatefulWidget> createState() => _CustomSortDialogState();
}
class _CustomSortDialogState extends State<CustomSortDialog> {
late List<String> _localSortedList;
@override
void initState() {
super.initState();
final state = context.read<TourBloc>().state;
if (state is TourLoaded) {
_localSortedList = [
...state.sortingInformation[widget.selectedCarId.toString()] ?? [],
];
} else {
_localSortedList = [];
}
}
Widget _information() {
return Padding(
padding: EdgeInsets.only(top: 15),
child: Column(
children: [
Padding(
padding: EdgeInsets.all(15),
child: Row(
children: [
Padding(
padding: EdgeInsets.only(right: 15),
child: Icon(Icons.info_outline, color: Colors.blueAccent),
),
Expanded(
child: Text(
"Ziehen Sie die einzelnen Lieferungen mit dem Finger in die gewünschte Position.",
),
),
],
),
),
Divider(),
_sortableList(),
],
),
);
}
Widget _sortableList() {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
final currentState = state;
if (currentState is TourLoaded) {
return Expanded(
child: ReorderableListView(
onReorder: (oldIndex, newIndex) {
setState(() {
_localSortedList = reorderList(
_localSortedList,
oldIndex,
newIndex,
);
});
context.read<TourBloc>().add(
ReorderDeliveryEvent(
newPosition: newIndex,
oldPosition: oldIndex,
carId: widget.selectedCarId.toString(),
),
);
},
children:
_localSortedList
.map((id) {
Delivery delivery = currentState.tour.deliveries
.firstWhere((delivery) => delivery.id == id);
int pos = _localSortedList.indexOf(id) + 1;
return ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
child: Text(
"$pos",
style: TextStyle(
color:
Theme.of(context).colorScheme.onSecondary,
),
),
),
title: Text(delivery.customer.name),
subtitle: Text(
delivery.customer.address.toString(),
style: TextStyle(fontSize: 11),
),
trailing: Icon(Icons.drag_handle),
key: Key("reorder-item-${delivery.id}"),
);
})
.toList(),
),
);
}
return Center(child: CircularProgressIndicator());
},
);
}
@override
Widget build(BuildContext context) {
return Dialog(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 20, right: 10, top: 15),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Fahrten sortieren",
style: Theme.of(context).textTheme.headlineSmall,
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
),
Expanded(child: _information()),
],
),
);
}
}

View File

@ -1,69 +1,32 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.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/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/feature/delivery/overview/presentation/delivery_overview.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';
import '../../bloc/tour_bloc.dart';
import '../../bloc/tour_state.dart';
/// Inhalt der Phase "Ausliefern". Sortieren und Beladen werden über eigene
/// Pages und das Phasen-Routing in `Home` gerendert — diese Page übernimmt
/// nur noch die letzte Phase. Der Phasen-Stepper bleibt sichtbar, damit der
/// Fahrer bei Bedarf zurückspringen kann; das BottomNav der Auslieferung
/// liegt im umgebenden `Home`-Scaffold.
class DeliveryOverviewPage extends StatefulWidget {
class DeliveryOverviewPage extends StatelessWidget {
const DeliveryOverviewPage({super.key});
@override
State<StatefulWidget> createState() => _DeliveryOverviewPageState();
}
class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
Widget _buildOverviewWithBanner({
required Tour tour,
required String bannerText,
}) {
return Column(
children: [
Material(
color: Colors.amber.shade100,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 12),
Expanded(child: Text(bannerText)),
],
),
),
),
Expanded(
child: DeliveryOverview(tour: tour),
),
],
);
}
@override
Widget build(BuildContext context) {
final carState = context.watch<CarSelectBloc>().state;
final carId = carState is CarSelectComplete
? carState.selectedCar.id.toString()
: "";
final carId =
carState is CarSelectComplete ? carState.selectedCar.id : '';
return Scaffold(
// Drawer ist hier ebenfalls aktiv, damit der Menü-Button des Steppers
// konsistent über alle Phasen funktioniert.
drawer: const HomeAppDrawer(),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(140),
@ -72,25 +35,79 @@ class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
carId: carId,
),
),
body: BlocBuilder<TourBloc, TourState>(
body: BlocConsumer<TourBloc, TourState>(
listenWhen: (prev, next) =>
next is TourLoaded && next.refreshError != null,
listener: (context, state) {
if (state is TourLoaded && state.refreshError != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.refreshError!)),
);
}
},
builder: (context, state) {
if (state is TourLoaded) {
if (state.distances == null) {
return _buildOverviewWithBanner(
tour: state.tour,
bannerText: "Berechne Distanzen…",
);
}
return DeliveryOverview(tour: state.tour);
switch (state) {
case TourLoaded(:final details):
return _OverviewBody(details: details);
case TourEmpty():
return const _EmptyTourBody();
case TourLoadFailed():
return const DeliveryLoadingFailedPage();
case TourInitial():
case TourLoading():
return const Center(child: CircularProgressIndicator());
}
if (state is TourLoadingFailed) {
return DeliveryLoadingFailedPage();
}
return const Center(child: CircularProgressIndicator());
},
),
);
}
}
class _OverviewBody extends StatelessWidget {
const _OverviewBody({required this.details});
final TourDetails details;
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () async {
context.read<TourBloc>().add(const RefreshTour());
},
child: DeliveryOverview(details: details),
);
}
}
class _EmptyTourBody extends StatelessWidget {
const _EmptyTourBody();
@override
Widget build(BuildContext context) {
// Wenn der ERP-Sync für heute keine Tour gemeldet hat, ist das ein
// normaler Zustand — kein Fehler. UX-Hinweis und Pull-to-refresh.
return RefreshIndicator(
onRefresh: () async {
context.read<TourBloc>().add(const RefreshTour());
},
child: ListView(
children: const [
SizedBox(height: 120),
Icon(Icons.event_busy, size: 64, color: Colors.grey),
SizedBox(height: 16),
Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: Text(
'Für heute ist keine Tour zugewiesen.\n'
'Zum Aktualisieren nach unten ziehen.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16),
),
),
),
],
),
);
}
}

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(),

View File

@ -1,5 +1,9 @@
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';
@ -12,9 +16,15 @@ import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
/// Page für die zweite Phase des Lieferprozesses (Sortieren). Der Fahrer
/// legt per Drag&Drop die Reihenfolge fest, ändert lokal so oft er möchte
/// und bestätigt am Ende mit einem expliziten Klick. Erst dann wird die
/// und bestätigt am Ende mit einem expliziten Klick. Dann wird die
/// Reihenfolge ans Backend übertragen und in Phase [DeliveryPhase.beladen]
/// gewechselt.
///
/// Hinweis zum Backend-Endpoint:
/// `PUT /tours/{id}/delivery-order` erwartet die **vollständige** Reihenfolge
/// aller Lieferungen der Tour. Bei Mehr-Auto-Teams sortiert der Fahrer
/// trotzdem nur „seine" Lieferungen — die Reihenfolge der fremden Lieferungen
/// wird unverändert anhand der aktuellen `sortOrder` mitgeschickt.
class DeliverySortPage extends StatefulWidget {
const DeliverySortPage({
super.key,
@ -23,9 +33,6 @@ class DeliverySortPage extends StatefulWidget {
});
final String selectedCarId;
/// Optionaler Hook, damit die übergeordnete Routing-Stelle nach Erfolg
/// auf die nächste Phase wechseln kann (rerender der Beladungs-Page).
final VoidCallback? onPhaseAdvanced;
@override
@ -33,82 +40,88 @@ class DeliverySortPage extends StatefulWidget {
}
class _DeliverySortPageState extends State<DeliverySortPage> {
late final SortableDeliveryListController _listController;
final SortableDeliveryListController _listController =
SortableDeliveryListController();
/// Verhindert mehrfache SnackBars für denselben Fehler-State.
String? _lastShownErrorSignature;
/// Letzter Tour-"Fingerabdruck" (Anzahl + erste/letzte ID), zu dem wir den
/// Sortier-Bucket des aktuellen Autos bereits konsistent gemacht haben.
/// Verhindert unnötige Event-Stürme, wenn der TourBloc häufig rebuildet.
String? _lastEnsuredTourSignature;
/// True, sobald `_confirm` gefeuert wurde — wir nutzen das, um den
/// Übergang persisting → fertig zuverlässig zu erkennen.
bool _isAwaitingConfirm = false;
/// Trackt, ob im letzten Listener-Tick `isPersistingSorting` true war —
/// damit wir den Übergang persisting → fertig zuverlässig erkennen,
/// auch wenn der Listener für Bucket-Maintenance bereits zwischendurch
/// gefeuert hat.
bool _wasPersisting = false;
@override
void initState() {
super.initState();
_listController = SortableDeliveryListController();
// Falls Tour bereits geladen ist: Sortier-Bucket sofort konsistent
// machen. Sonst übernimmt das der BlocConsumer-Listener beim ersten
// TourLoaded.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_ensureBucketIfNeeded(context.read<TourBloc>().state);
});
bool _multiCarTeam(BuildContext context) {
final carsState = context.read<CarsBloc>().state;
return carsState is CarsLoaded && carsState.cars.length >= 2;
}
String _tourSignature(TourLoaded state) {
final ids = state.tour.deliveries.map((d) => d.id).toList();
if (ids.isEmpty) return "0";
return "${ids.length}|${ids.first}|${ids.last}";
List<Delivery> _ownDeliveries(TourDetails details) {
if (_multiCarTeam(context)) {
return details.deliveriesSorted
.where((d) => d.assignedCarId == widget.selectedCarId)
.toList();
}
return details.deliveriesSorted;
}
void _ensureBucketIfNeeded(TourState state) {
if (state is! TourLoaded) return;
final signature = _tourSignature(state);
if (signature == _lastEnsuredTourSignature) return;
_lastEnsuredTourSignature = signature;
context.read<TourBloc>().add(
EnsureSortingForCarEvent(carId: widget.selectedCarId.toString()),
);
/// Baut die vollständige Tour-Reihenfolge: die fremden Lieferungen bleiben
/// in ihrer aktuellen `sortOrder`, die eigenen werden in der vom Fahrer
/// gewählten Reihenfolge eingefügt — an den Positionen, an denen sie
/// vorher waren. Beispiel: hat der Fahrer „eigene" Plätze 1, 3, 5
/// belegt und sortiert lokal um, dann landen seine neuen Reihen-Ids
/// in derselben Reihenfolge auf den Positionen 1, 3, 5; fremde Items
/// behalten ihre Plätze 2, 4.
List<String> _buildFullTourOrder(
TourDetails details,
List<String> ownOrderedIds,
) {
final sorted = details.deliveriesSorted;
if (!_multiCarTeam(context)) return ownOrderedIds;
final ownSlotsByPosition = <int, String>{};
final foreignByPosition = <int, String>{};
for (var i = 0; i < sorted.length; i++) {
final d = sorted[i];
if (d.assignedCarId == widget.selectedCarId) {
ownSlotsByPosition[i] = d.id;
} else {
foreignByPosition[i] = d.id;
}
}
final ownPositions = ownSlotsByPosition.keys.toList()..sort();
final result = List<String?>.filled(sorted.length, null);
for (final entry in foreignByPosition.entries) {
result[entry.key] = entry.value;
}
for (var i = 0; i < ownPositions.length && i < ownOrderedIds.length; i++) {
result[ownPositions[i]] = ownOrderedIds[i];
}
// Schließe verbleibende Lücken auf — sollte normalerweise leer sein.
return result.whereType<String>().toList();
}
List<String> _orderedIdsFor(TourLoaded state) {
return state.sortingInformation[widget.selectedCarId.toString()] ??
const <String>[];
}
/// Phase im zentralen [PhaseBloc] auf "beladen" setzen. Der Bloc kümmert
/// sich um Persistenz via [PhaseService]. Der optionale UI-Callback wird
/// (aus Rückwärtskompatibilität) zusätzlich gefeuert.
void _advanceToLoading() {
context.read<PhaseBloc>().add(
PhaseSet(
carId: widget.selectedCarId.toString(),
carId: widget.selectedCarId,
phase: DeliveryPhase.beladen,
),
);
widget.onPhaseAdvanced?.call();
}
void _skipEmptyToLoading() => _advanceToLoading();
void _confirm() {
// _wasPersisting hier setzen — der erste Listener-Tick mit
// isPersistingSorting=true wird von listenWhen herausgefiltert
// (kein "phaseEnded"-Übergang), daher müssen wir das Flag vorher
// selbst hochziehen. Sonst erkennt der Listener beim zweiten Tick
// (isPersistingSorting=false) den Übergang nicht und der
// Phasen-Wechsel auf "beladen" bleibt aus.
_wasPersisting = true;
void _confirm(TourDetails details) {
final ownOrder = _listController.readCurrentOrder();
final fullOrder = _buildFullTourOrder(details, ownOrder);
if (fullOrder.isEmpty) {
_advanceToLoading();
return;
}
_isAwaitingConfirm = true;
context.read<TourBloc>().add(
ConfirmSortingEvent(carId: widget.selectedCarId.toString()),
);
ReorderDeliveries(orderedDeliveryIds: fullOrder),
);
}
Widget _hintCard({required IconData icon, required String text, Color? color}) {
@ -133,14 +146,14 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
const Icon(Icons.inbox_outlined, size: 64, color: Colors.grey),
const SizedBox(height: 12),
Text(
"Keine Lieferungen heute",
'Keine Lieferungen heute',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: Text(
"Für das ausgewählte Fahrzeug sind heute keine Lieferungen geplant.",
'Für das ausgewählte Fahrzeug sind heute keine Lieferungen geplant.',
textAlign: TextAlign.center,
),
),
@ -148,33 +161,29 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
);
}
Widget _singleDeliveryHint(String singleId, TourLoaded state) {
final delivery = state.tour.deliveries.firstWhere(
(d) => d.id == singleId,
orElse: () => state.tour.deliveries.first,
);
Widget _singleDeliveryHint(Delivery single, TourDetails details) {
final customer = details.customerOf(single);
return Column(
children: [
_hintCard(
icon: Icons.info_outline,
text:
"Nur eine Lieferung — die Reihenfolge ist trivial. "
"Tippen Sie auf \"Weiter zur Beladung\", um fortzufahren.",
text: 'Nur eine Lieferung — die Reihenfolge ist trivial. '
'Tippen Sie auf "Weiter zur Beladung", um fortzufahren.',
),
const Divider(),
ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
child: Text(
"1",
'1',
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
),
),
),
title: Text(delivery.customer.name),
title: Text(customer?.name ?? '⟨Unbekannter Kunde⟩'),
subtitle: Text(
delivery.customer.address.toString(),
single.deliveryAddressSnapshot.oneLine,
style: const TextStyle(fontSize: 11),
),
),
@ -186,55 +195,48 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
Widget build(BuildContext context) {
return BlocConsumer<TourBloc, TourState>(
listenWhen: (prev, curr) {
// Bucket-Maintenance: erstes TourLoaded oder Tour-Inhalt geändert.
final bucketTrigger = (prev is! TourLoaded && curr is TourLoaded) ||
(prev is TourLoaded &&
curr is TourLoaded &&
prev.tour.deliveries.length != curr.tour.deliveries.length);
if (bucketTrigger) return true;
if (prev is! TourLoaded || curr is! TourLoaded) return false;
final phaseEnded =
prev.isPersistingSorting && !curr.isPersistingSorting;
final newError =
curr.sortingPersistError != null &&
curr.sortingPersistError != prev.sortingPersistError;
prev.isPersistingReorder && !curr.isPersistingReorder;
final newError = curr.reorderError != null &&
curr.reorderError != prev.reorderError;
return phaseEnded || newError;
},
listener: (context, state) {
if (state is! TourLoaded) return;
// 1) Bucket-Konsistenz nachziehen, falls die Tour gerade erst geladen
// wurde oder sich verändert hat.
_ensureBucketIfNeeded(state);
// 2) Fehler-SnackBar für Confirm-Fehler.
final err = state.sortingPersistError;
final err = state.reorderError;
if (err != null && err != _lastShownErrorSignature) {
_lastShownErrorSignature = err;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err)),
);
_wasPersisting = state.isPersistingSorting;
_isAwaitingConfirm = false;
return;
}
// 3) Echter Übergang persisting → fertig + kein Fehler → erfolgreich
// bestätigt → nächste Phase. Übergang über das Feld _wasPersisting
// erkannt, da der Listener auch bei Bucket-Triggern feuert.
if (_wasPersisting && !state.isPersistingSorting && err == null) {
if (_isAwaitingConfirm &&
!state.isPersistingReorder &&
err == null) {
_isAwaitingConfirm = false;
_advanceToLoading();
}
_wasPersisting = state.isPersistingSorting;
},
builder: (context, state) {
if (state is TourEmpty) {
return Scaffold(
appBar: AppBar(title: const Text('Sortieren')),
body: _emptyState(),
);
}
if (state is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final orderedIds = _orderedIdsFor(state);
final details = state.details;
final ownDeliveries = _ownDeliveries(details);
return Scaffold(
drawer: const HomeAppDrawer(),
@ -242,40 +244,41 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
preferredSize: const Size.fromHeight(140),
child: PhaseStepper(
currentPhase: DeliveryPhase.sortieren,
carId: widget.selectedCarId.toString(),
carId: widget.selectedCarId,
),
),
body: SafeArea(
child: _buildBody(state, orderedIds),
),
bottomNavigationBar: _buildBottomBar(state, orderedIds),
body: SafeArea(child: _buildBody(state, details, ownDeliveries)),
bottomNavigationBar:
_buildBottomBar(state, details, ownDeliveries),
);
},
);
}
Widget _buildBody(TourLoaded state, List<String> orderedIds) {
if (orderedIds.isEmpty) {
Widget _buildBody(
TourLoaded state,
TourDetails details,
List<Delivery> ownDeliveries,
) {
if (ownDeliveries.isEmpty) {
return _emptyState();
}
if (orderedIds.length == 1) {
return _singleDeliveryHint(orderedIds.first, state);
if (ownDeliveries.length == 1) {
return _singleDeliveryHint(ownDeliveries.first, details);
}
return Column(
children: [
_hintCard(
icon: Icons.info_outline,
text:
"Ziehen Sie die einzelnen Lieferungen mit dem Finger in die "
"gewünschte Position. Die Reihenfolge wird erst beim "
"Bestätigen ans System übertragen.",
text: 'Ziehen Sie die einzelnen Lieferungen mit dem Finger in die '
'gewünschte Position. Die Reihenfolge wird erst beim '
'Bestätigen ans System übertragen.',
),
const Divider(height: 1),
Expanded(
child: SortableDeliveryList(
selectedCarId: widget.selectedCarId,
details: details,
deliveries: ownDeliveries,
controller: _listController,
),
),
@ -283,35 +286,38 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
);
}
Widget _buildBottomBar(TourLoaded state, List<String> orderedIds) {
final isLoading = state.isPersistingSorting;
Widget _buildBottomBar(
TourLoaded state,
TourDetails details,
List<Delivery> ownDeliveries,
) {
final isLoading = state.isPersistingReorder;
final theme = Theme.of(context);
// Spezialfälle: 0 / 1 Lieferungen → vereinfachte BottomBar
if (orderedIds.isEmpty) {
if (ownDeliveries.isEmpty) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: isLoading ? null : _skipEmptyToLoading,
onPressed: isLoading ? null : _advanceToLoading,
icon: const Icon(Icons.arrow_forward),
label: const Text("Weiter zur Beladung"),
label: const Text('Weiter zur Beladung'),
),
),
),
);
}
if (orderedIds.length == 1) {
if (ownDeliveries.length == 1) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: isLoading ? null : _confirm,
onPressed: isLoading ? null : () => _confirm(details),
icon: isLoading
? const SizedBox(
width: 16,
@ -322,7 +328,7 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
),
)
: const Icon(Icons.arrow_forward),
label: const Text("Weiter zur Beladung"),
label: const Text('Weiter zur Beladung'),
),
),
),
@ -336,18 +342,16 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: isLoading
? null
: () => _listController.resetToDefault(),
onPressed: isLoading ? null : _listController.resetToDefault,
icon: const Icon(Icons.restart_alt),
label: const Text("Zurücksetzen"),
label: const Text('Zurücksetzen'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: FilledButton.icon(
onPressed: isLoading ? null : _confirm,
onPressed: isLoading ? null : () => _confirm(details),
icon: isLoading
? SizedBox(
width: 16,
@ -358,7 +362,7 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
),
)
: const Icon(Icons.check),
label: const Text("Reihenfolge bestätigen"),
label: const Text('Reihenfolge bestätigen'),
),
),
],

View File

@ -1,82 +0,0 @@
import 'package:flutter/cupertino.dart';
import 'package:geolocator/geolocator.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class DistanceService {
static const String GOOGLE_MAPS_API_KEY = 'DEIN_API_KEY_HIER';
static Future<Position> getCurrentLocation() async {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
throw Exception('Location services sind deaktiviert');
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
return await Geolocator.getCurrentPosition();
}
// Adresse in Koordinaten umwandeln (Geocoding)
static Future<Map<String, double>> getCoordinates(String address) async {
String url =
'https://maps.googleapis.com/maps/api/geocode/json'
'?address=${Uri.encodeComponent(address)}'
'&key=AIzaSyB5_1ftLnoswoy59FzNFkrQ7SSDma5eu5E';
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
var json = jsonDecode(response.body);
if (json['results'].isNotEmpty) {
var location = json['results'][0]['geometry']['location'];
return {
'lat': location['lat'],
'lng': location['lng'],
};
}
throw Exception('Adresse nicht gefunden');
}
throw Exception('Geocoding Fehler: ${response.statusCode}');
}
// Distanz berechnen
static Future<double> getDistanceByRoad(String address) async {
try {
Position currentPos = await getCurrentLocation();
Map<String, double> coords = await getCoordinates(address);
String origin = "${currentPos.latitude},${currentPos.longitude}";
String destination = "${coords['lat']},${coords['lng']}";
String url =
'https://maps.googleapis.com/maps/api/distancematrix/json'
'?origins=$origin'
'&destinations=$destination'
'&key=AIzaSyB5_1ftLnoswoy59FzNFkrQ7SSDma5eu5E';
final response = await http.get(Uri.parse(url));
debugPrint(response.body);
if (response.statusCode == 200) {
var json = jsonDecode(response.body);
if (json['rows'][0]['elements'][0]['status'] == 'OK') {
int distanceMeters = json['rows'][0]['elements'][0]['distance']['value'];
return distanceMeters / 1000; // In km
} else {
throw Exception('Route nicht gefunden');
}
} else {
throw Exception('API Fehler: ${response.statusCode}');
}
} catch (e) {
throw Exception('Fehler: $e');
}
}
}

View File

@ -1,57 +1,52 @@
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Persistiert die aktuelle Phase pro Fahrzeug. Der Key ist datumsspezifisch,
/// damit ein App-Neustart am nächsten Tag automatisch wieder mit Phase 1
/// (Sortieren) startet — die Phase eines Vortags hat keine Bedeutung mehr.
/// Persistiert die aktuelle Phase pro Fahrzeug.
///
/// Zusätzlich wird die **höchste am Tag erreichte Phase** pro Fahrzeug
/// persistiert (eigener Key-Suffix `_max`). Der Stepper nutzt diesen Wert,
/// um Vorwärts-Sprünge auf bereits besuchte Phasen zu erlauben — auch wenn
/// der Fahrer zwischenzeitlich zurückgesprungen ist.
/// Der Key ist an einen **Tour-Token** gebunden (abgeleitet aus
/// `Tour.syncedAt`) statt nur an das Datum. Vorteile:
///
/// * Ein erneuter ERP-Sync / Demo-Seed schreibt eine neue `syncedAt` → neuer
/// Token → die Phasen (inkl. der „erledigt"-Häkchen im Stepper) starten
/// frisch. So bleibt ein „Daten-Reset" im Backend nicht an alten lokalen
/// Häkchen hängen.
/// * Eine Tour von heute hat heutiges `syncedAt` — die Tagesbindung ist
/// damit implizit (am nächsten Tag gibt es ohnehin eine neue Tour).
/// * Bloßes Weiterscannen (Item-Status) ändert `syncedAt` nicht → der
/// Fahrer-Fortschritt bleibt über App-Neustarts derselben Tour erhalten.
///
/// Zusätzlich wird die **höchste erreichte Phase** pro Fahrzeug persistiert
/// (Key-Suffix `_max`). Der Stepper nutzt das, um Vorwärts-Sprünge auf
/// bereits besuchte Phasen zu erlauben — auch nach einem Rücksprung.
class PhaseService {
static const _prefix = "delivery_phase";
String _key(String carId) {
final now = DateTime.now();
final date = "${now.year}_${now.month}_${now.day}";
return "${_prefix}_${date}_$carId";
}
String _key(String carId, String token) => "${_prefix}_${token}_$carId";
String _maxKey(String carId) {
final now = DateTime.now();
final date = "${now.year}_${now.month}_${now.day}";
return "${_prefix}_max_${date}_$carId";
}
String _maxKey(String carId, String token) =>
"${_prefix}_max_${token}_$carId";
Future<void> save(String carId, DeliveryPhase phase) async {
Future<void> save(String carId, String token, DeliveryPhase phase) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key(carId), phase.persistenceKey);
await prefs.setString(_key(carId, token), phase.persistenceKey);
}
Future<DeliveryPhase?> load(String carId) async {
final prefs = await SharedPreferences.getInstance();
return DeliveryPhaseExtension.fromPersistenceKey(prefs.getString(_key(carId)));
}
Future<void> saveMax(String carId, DeliveryPhase phase) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_maxKey(carId), phase.persistenceKey);
}
Future<DeliveryPhase?> loadMax(String carId) async {
Future<DeliveryPhase?> load(String carId, String token) async {
final prefs = await SharedPreferences.getInstance();
return DeliveryPhaseExtension.fromPersistenceKey(
prefs.getString(_maxKey(carId)),
prefs.getString(_key(carId, token)),
);
}
Future<Map<String, DeliveryPhase>> loadAll(Iterable<String> carIds) async {
final result = <String, DeliveryPhase>{};
for (final carId in carIds) {
final phase = await load(carId);
if (phase != null) result[carId] = phase;
}
return result;
Future<void> saveMax(String carId, String token, DeliveryPhase phase) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_maxKey(carId, token), phase.persistenceKey);
}
Future<DeliveryPhase?> loadMax(String carId, String token) async {
final prefs = await SharedPreferences.getInstance();
return DeliveryPhaseExtension.fromPersistenceKey(
prefs.getString(_maxKey(carId, token)),
);
}
}

View File

@ -1,74 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:path_provider/path_provider.dart';
class ReorderService {
get _path async {
final dir = await getApplicationDocumentsDirectory();
final date = DateTime.now();
final filename = "custom_sort_${date.year}_${date.month}_${date.day}.json";
final path = "${dir.path}/$filename";
return path;
}
Future<File> get _file async {
final path = await _path;
final file = File(path);
return file;
}
Future<void> saveSortingInformation(
Map<String, List<String>> container,
) async {
debugPrint("CONTAINER: ${jsonEncode(container)}");
(await _file).writeAsString(jsonEncode(container));
}
Future<void> initializeTour(Tour tour) async {
(await _file).create();
Map<String, List<String>> sorting = {};
for (final delivery in tour.deliveries) {
if (!sorting.containsKey(delivery.carId.toString())) {
sorting[delivery.carId.toString()] = [delivery.id];
} else {
sorting[delivery.carId.toString()]!.add(delivery.id);
}
}
(await _file).writeAsString(jsonEncode({"cars": sorting}));
}
bool orderInformationExist() {
return false;
}
Future<Map<String, List<String>>> loadSortingInformation() async {
debugPrint("FILE: ${await (await _file).readAsString()}");
Map<String, List<String>> container = {};
Map<String, dynamic> json = jsonDecode(await (await _file).readAsString());
if (!json.containsKey("cars")) {
throw Exception("No cars found in file");
}
for (final car in json["cars"].entries) {
List<String> values = [];
for (String value in car.value) {
values.add(value);
}
container[car.key] = values;
}
return container;
}
}

View File

@ -1,30 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/util.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
/// Drag&Drop-Liste der heutigen Lieferungen eines Fahrzeugs.
///
/// Hält die aktuell sichtbare Reihenfolge in lokalem State, damit das
/// Verschieben spürbar unmittelbar wirkt — und gibt jeden Drop zusätzlich
/// als [ReorderDeliveryEvent] an den [TourBloc] weiter. Dort übernimmt
/// der [ReorderService] die lokale Persistenz. Es findet hier bewusst
/// **kein** API-Call statt; der Backend-Sync läuft erst, wenn der Fahrer
/// die Reihenfolge in der übergeordneten Page bestätigt.
/// Hält die aktuell sichtbare Reihenfolge in lokalem State; ein Tour-Reload
/// von außen würde sie überschreiben — das ist Absicht, damit der Backend-
/// Stand bei Pull-to-refresh durchschlägt. Der eigentliche Backend-Sync
/// erfolgt erst, wenn der Fahrer in der übergeordneten Page bestätigt.
class SortableDeliveryList extends StatefulWidget {
const SortableDeliveryList({
super.key,
required this.selectedCarId,
required this.details,
required this.deliveries,
this.controller,
});
final String? selectedCarId;
/// Aggregat-Snapshot — wird für Kunden-Lookup gebraucht.
final TourDetails details;
/// Optionaler Controller zum Zurücksetzen der Liste durch Eltern-Widgets
/// (z. B. Button "Zurücksetzen" in der Page).
/// Die in der Liste anzuzeigenden Lieferungen, vorgefiltert vom Aufrufer
/// (z. B. nur die dem ausgewählten Fahrzeug zugewiesenen).
final List<Delivery> deliveries;
/// Optionaler Controller zum Auslesen der aktuellen Reihenfolge und zum
/// Zurücksetzen durch Eltern-Widgets.
final SortableDeliveryListController? controller;
@override
@ -32,12 +32,12 @@ class SortableDeliveryList extends StatefulWidget {
}
class _SortableDeliveryListState extends State<SortableDeliveryList> {
late List<String> _localSortedList;
late List<String> _orderedIds;
@override
void initState() {
super.initState();
_localSortedList = _readSortedListFromBloc();
_orderedIds = widget.deliveries.map((d) => d.id).toList(growable: true);
widget.controller?._attach(this);
}
@ -48,6 +48,17 @@ class _SortableDeliveryListState extends State<SortableDeliveryList> {
oldWidget.controller?._detach(this);
widget.controller?._attach(this);
}
// Wenn sich die Eingangsliste fundamental ändert (Tour-Reload, neue
// Lieferung hinzu/weg), local-state neu synchronisieren.
final incomingIds = widget.deliveries.map((d) => d.id).toSet();
final localIds = _orderedIds.toSet();
if (incomingIds.length != localIds.length ||
!incomingIds.containsAll(localIds)) {
setState(() {
_orderedIds =
widget.deliveries.map((d) => d.id).toList(growable: true);
});
}
}
@override
@ -56,115 +67,64 @@ class _SortableDeliveryListState extends State<SortableDeliveryList> {
super.dispose();
}
List<String> _readSortedListFromBloc() {
final state = context.read<TourBloc>().state;
if (state is TourLoaded) {
return [
...state.sortingInformation[widget.selectedCarId.toString()] ?? [],
];
}
return [];
}
/// Setzt die Liste auf die natürliche Reihenfolge zurück, in der die
/// Lieferungen in der Tour stehen. Wird vom Controller (Button
/// "Zurücksetzen") aufgerufen und meldet jeden notwendigen Swap als
/// Reorder-Event, damit der lokale Persistenz-State synchron bleibt.
///
/// Filterlogik (muss konsistent zu `_ensureSortingForCar` im TourBloc
/// sein):
/// * Ein-Auto-Teams: alle Tour-Lieferungen.
/// * Mehr-Auto-Teams: nur Lieferungen, die dem ausgewählten Fahrzeug
/// nach der Auswahl bereits zugeordnet sind.
void _resetToDefault() {
final state = context.read<TourBloc>().state;
if (state is! TourLoaded) return;
final cars = state.tour.driver.cars;
final carIdStr = widget.selectedCarId.toString();
final List<String> defaultOrder = cars.length >= 2
? state.tour.deliveries
.where((d) => d.carId?.toString() == carIdStr)
.map((d) => d.id)
.toList()
: state.tour.deliveries.map((d) => d.id).toList();
setState(() {
_localSortedList = [...defaultOrder];
_orderedIds =
widget.deliveries.map((d) => d.id).toList(growable: true);
});
final container = {
...state.sortingInformation,
carIdStr: [...defaultOrder],
};
context.read<TourBloc>().add(
ReplaceSortingEvent(
carId: carIdStr,
newSortingInformation: container,
),
);
}
List<String> _readCurrentOrder() => List<String>.of(_orderedIds);
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is! TourLoaded) {
return const Center(child: CircularProgressIndicator());
}
final byId = {for (final d in widget.deliveries) d.id: d};
return ReorderableListView(
buildDefaultDragHandles: true,
onReorder: (oldIndex, newIndex) {
setState(() {
_localSortedList = reorderList(
_localSortedList,
oldIndex,
newIndex,
);
});
context.read<TourBloc>().add(
ReorderDeliveryEvent(
newPosition: newIndex,
oldPosition: oldIndex,
carId: widget.selectedCarId.toString(),
),
);
},
children: _localSortedList.map((id) {
final Delivery delivery = state.tour.deliveries.firstWhere(
(delivery) => delivery.id == id,
);
final int pos = _localSortedList.indexOf(id) + 1;
return ListTile(
key: Key("reorder-item-${delivery.id}"),
leading: CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
child: Text(
"$pos",
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
),
),
),
title: Text(delivery.customer.name),
subtitle: Text(
delivery.customer.address.toString(),
style: const TextStyle(fontSize: 11),
),
trailing: const Icon(Icons.drag_handle),
);
}).toList(),
);
return ReorderableListView(
buildDefaultDragHandles: true,
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex -= 1;
final id = _orderedIds.removeAt(oldIndex);
_orderedIds.insert(newIndex, id);
});
},
children: _orderedIds.asMap().entries.map((entry) {
final id = entry.value;
final pos = entry.key + 1;
final delivery = byId[id];
if (delivery == null) {
return ListTile(
key: Key('reorder-item-orphan-$id'),
title: Text('Lieferung $id nicht mehr in der Tour'),
);
}
final customer = widget.details.customerOf(delivery);
return ListTile(
key: Key('reorder-item-${delivery.id}'),
leading: CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
child: Text(
'$pos',
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
),
),
),
title: Text(customer?.name ?? '⟨Unbekannter Kunde⟩'),
subtitle: Text(
delivery.deliveryAddressSnapshot.oneLine,
style: const TextStyle(fontSize: 11),
),
trailing: const Icon(Icons.drag_handle),
);
}).toList(),
);
}
}
/// Schmaler Controller, mit dem Eltern-Widgets die Liste zurücksetzen
/// können, ohne den internen State direkt anzufassen.
/// und die aktuelle Reihenfolge auslesen können.
class SortableDeliveryListController {
_SortableDeliveryListState? _state;
@ -173,6 +133,11 @@ class SortableDeliveryListController {
if (_state == state) _state = null;
}
/// Setzt die Liste auf die Default-Reihenfolge (Tour-Reihenfolge) zurück.
/// Setzt die Liste auf die vom Aufrufer übergebene Default-Reihenfolge
/// zurück (= aktueller `widget.deliveries`-Stand).
void resetToDefault() => _state?._resetToDefault();
/// Aktuelle Reihenfolge der Delivery-IDs, wie sie der Fahrer sieht.
List<String> readCurrentOrder() =>
_state?._readCurrentOrder() ?? const <String>[];
}