From 2ff331294e543d3eef2210ae8f27f05ab506cf3e Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Fri, 25 Sep 2026 17:04:56 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Tour-Datum=20=E2=80=94=20Leer-Texte,=20v?= =?UTF-8?q?eraltete=20Antworten,=20Kalenderfenster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Leer-Ansichten (Auswählen, Sortieren, Beladen, Ausliefern) nennen den gewählten Tag statt immer „heute": „Für den 02.09.2026 ist keine Tour zugewiesen." (TourDayText). - TourBloc: Loads/Refreshes laufen nebenläufig; bei schnellem Datumswechsel konnte die langsamere Antwort des vorigen Tages die Tour des gewählten Tages überschreiben. Jetzt schreibt nur die jüngste Anfrage ihr Ergebnis. - Kalender reicht 90 statt 14 Tage zurück (z. B. nachimportierte Touren vom Monatsanfang). - Test: neuer Fahrer startet auf „heute", auch wenn der vorige einen anderen Tag gewählt hatte. Co-Authored-By: Claude Opus 5.5 --- lib/feature/delivery/bloc/tour_bloc.dart | 12 +++ lib/feature/delivery/model/tour_day_text.dart | 40 ++++++++ .../presentation/delivery_overview_page.dart | 19 ++-- .../presentation/delivery_selection_page.dart | 11 ++- .../presentation/delivery_sort_page.dart | 14 ++- .../presentation/loading_overview_page.dart | 5 +- lib/widget/phase_stepper/phase_stepper.dart | 4 +- test/session_scope_test.dart | 4 + test/tour_date_test.dart | 98 +++++++++++++++++++ 9 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 lib/feature/delivery/model/tour_day_text.dart create mode 100644 test/tour_date_test.dart diff --git a/lib/feature/delivery/bloc/tour_bloc.dart b/lib/feature/delivery/bloc/tour_bloc.dart index eab7ec1..6662d53 100644 --- a/lib/feature/delivery/bloc/tour_bloc.dart +++ b/lib/feature/delivery/bloc/tour_bloc.dart @@ -72,6 +72,12 @@ class TourBloc extends Bloc { /// damit ein Pull-to-refresh denselben Tag neu lädt (nicht heute). DateTime? _selectedDate; + /// Laufnummer der Tour-Anfragen. Loads/Refreshes laufen nebenläufig; wählt + /// der Fahrer schnell nacheinander zwei Tage, darf die langsamere Antwort + /// des ersten Tages die des zuletzt gewählten nicht überschreiben. Nur die + /// jüngste Anfrage schreibt ihr Ergebnis. + int _requestSeq = 0; + /// Räumt verwaiste Cache-Bilder weg, sobald frische Tour-Daten vorliegen. /// Fire-and-forget — Cache-Pflege darf den Load nie verzögern oder /// scheitern lassen. @@ -85,9 +91,11 @@ class TourBloc extends Bloc { // Nur bei explizit gesetztem Datum die Wahl aktualisieren; ein reiner // Reload (LoadTour()) behält den aktuell angezeigten Tag. if (event.hasDate) _selectedDate = event.date; + final seq = ++_requestSeq; emit(const TourLoading()); try { final details = await tourRepository.getMyTourDetails(date: _selectedDate); + if (seq != _requestSeq) return; // überholt von neuerer Anfrage if (details == null) { emit(const TourEmpty()); return; @@ -95,6 +103,7 @@ class TourBloc extends Bloc { emit(TourLoaded(details: details)); _pruneAttachmentCache(details); } catch (e, st) { + if (seq != _requestSeq) return; debugPrint('TourBloc.LoadTour fehlgeschlagen: $e\n$st'); final message = _messageOf(e, 'Tour konnte nicht geladen werden'); emit(TourLoadFailed(message: message)); @@ -113,9 +122,11 @@ class TourBloc extends Bloc { return; } + final seq = ++_requestSeq; emit(current.copyWith(isRefreshing: true, refreshError: null)); try { final details = await tourRepository.getMyTourDetails(date: _selectedDate); + if (seq != _requestSeq) return; // überholt von neuerer Anfrage if (details == null) { emit(const TourEmpty()); return; @@ -123,6 +134,7 @@ class TourBloc extends Bloc { emit(TourLoaded(details: details)); _pruneAttachmentCache(details); } catch (e, st) { + if (seq != _requestSeq) return; debugPrint('TourBloc.RefreshTour fehlgeschlagen: $e\n$st'); final message = _messageOf(e, 'Tour konnte nicht neu geladen werden'); // alten Stand sichtbar lassen, Fehler oben mitführen diff --git a/lib/feature/delivery/model/tour_day_text.dart b/lib/feature/delivery/model/tour_day_text.dart new file mode 100644 index 0000000..66c8d96 --- /dev/null +++ b/lib/feature/delivery/model/tour_day_text.dart @@ -0,0 +1,40 @@ +/// Formulierung des gewählten Tour-Tags für Fließtexte, z. B. in den +/// Leer-Ansichten („Für heute ist keine Tour zugewiesen." bzw. „Für den +/// 22.09.2026 …"). +/// +/// `null` als gewähltes Datum heißt „heute" (Server-Default, siehe +/// `TourDateCubit`). Heute/morgen/gestern werden relativ zur Geräte-Uhr +/// benannt, alle anderen Tage mit Datum. +class TourDayText { + const TourDayText._(this._relative, this.date); + + factory TourDayText.of(DateTime? selected, {DateTime? now}) { + final clock = now ?? DateTime.now(); + final today = DateTime(clock.year, clock.month, clock.day); + final day = selected == null + ? today + : DateTime(selected.year, selected.month, selected.day); + final relative = switch (day.difference(today).inDays) { + 0 => 'heute', + 1 => 'morgen', + -1 => 'gestern', + _ => null, + }; + return TourDayText._(relative, _format(day)); + } + + final String? _relative; + + /// Datum als „TT.MM.JJJJ". + final String date; + + /// Umstandsangabe: „heute" / „am 22.09.2026". + String get when => _relative ?? 'am $date'; + + /// Nach „für": „heute" / „den 22.09.2026". + String get afterFor => _relative ?? 'den $date'; + + static String _format(DateTime d) => + '${d.day.toString().padLeft(2, '0')}.' + '${d.month.toString().padLeft(2, '0')}.${d.year}'; +} diff --git a/lib/feature/delivery/overview/presentation/delivery_overview_page.dart b/lib/feature/delivery/overview/presentation/delivery_overview_page.dart index 9166204..7b8bcf7 100644 --- a/lib/feature/delivery/overview/presentation/delivery_overview_page.dart +++ b/lib/feature/delivery/overview/presentation/delivery_overview_page.dart @@ -11,6 +11,8 @@ import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart'; import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart'; import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart'; +import 'package:hl_lieferservice/feature/delivery/model/tour_day_text.dart'; /// Inhalt der Phase "Ausliefern". Sortieren und Beladen werden über eigene /// Pages und das Phasen-Routing in `Home` gerendert — diese Page übernimmt @@ -77,25 +79,26 @@ class _EmptyTourBody extends StatelessWidget { @override Widget build(BuildContext context) { - // Wenn der ERP-Sync für heute keine Tour gemeldet hat, ist das ein + // Wenn der ERP-Sync für den Tag keine Tour gemeldet hat, ist das ein // normaler Zustand — kein Fehler. UX-Hinweis und Pull-to-refresh. return RefreshIndicator( onRefresh: () async { context.read().add(const RefreshTour()); }, child: ListView( - children: const [ - SizedBox(height: 120), - Icon(Icons.event_busy, size: 64, color: Colors.grey), - SizedBox(height: 16), + children: [ + const SizedBox(height: 120), + const Icon(Icons.event_busy, size: 64, color: Colors.grey), + const SizedBox(height: 16), Center( child: Padding( - padding: EdgeInsets.symmetric(horizontal: 32), + padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( - 'Für heute ist keine Tour zugewiesen.\n' + 'Für ${TourDayText.of(context.watch().state).afterFor} ' + 'ist keine Tour zugewiesen.\n' 'Zum Aktualisieren nach unten ziehen.', textAlign: TextAlign.center, - style: TextStyle(fontSize: 16), + style: const TextStyle(fontSize: 16), ), ), ), diff --git a/lib/feature/delivery/overview/presentation/delivery_selection_page.dart b/lib/feature/delivery/overview/presentation/delivery_selection_page.dart index 3350b1e..9b94cdf 100644 --- a/lib/feature/delivery/overview/presentation/delivery_selection_page.dart +++ b/lib/feature/delivery/overview/presentation/delivery_selection_page.dart @@ -14,6 +14,8 @@ import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart'; import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart'; import 'package:hl_lieferservice/widget/orderer_hint.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart'; +import 'package:hl_lieferservice/feature/delivery/model/tour_day_text.dart'; /// Page für die erste Phase bei Mehr-Auto-Teams: Auswählen der eigenen /// Lieferungen aus dem gemeinsamen Tour-Pool. @@ -388,12 +390,13 @@ class _DeliverySelectionPageState extends State { return Scaffold( drawer: const HomeAppDrawer(), appBar: AppBar(title: const Text('Lieferungen auswählen')), - body: const Center( + body: Center( child: Padding( - padding: EdgeInsets.all(24), + padding: const EdgeInsets.all(24), child: Text( - 'Für heute ist keine Tour zugewiesen.', - style: TextStyle(fontSize: 16), + 'Für ${TourDayText.of(context.watch().state).afterFor} ' + 'ist keine Tour zugewiesen.', + style: const TextStyle(fontSize: 16), textAlign: TextAlign.center, ), ), diff --git a/lib/feature/delivery/overview/presentation/delivery_sort_page.dart b/lib/feature/delivery/overview/presentation/delivery_sort_page.dart index f89ba76..a604f70 100644 --- a/lib/feature/delivery/overview/presentation/delivery_sort_page.dart +++ b/lib/feature/delivery/overview/presentation/delivery_sort_page.dart @@ -15,6 +15,8 @@ import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart'; import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart'; import 'package:hl_lieferservice/widget/orderer_hint.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart'; +import 'package:hl_lieferservice/feature/delivery/model/tour_day_text.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 @@ -142,25 +144,27 @@ class _DeliverySortPageState extends State { } Widget _emptyState() { + final day = TourDayText.of(context.watch().state); return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.inbox_outlined, size: 64, color: Colors.grey), const SizedBox(height: 12), Text( - 'Keine Lieferungen heute', + 'Keine Lieferungen ${day.when}', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 6), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 32), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( - 'Für das ausgewählte Fahrzeug sind heute keine Lieferungen geplant.', + 'Für das ausgewählte Fahrzeug sind ${day.when} keine ' + 'Lieferungen geplant.', textAlign: TextAlign.center, ), ), const SizedBox(height: 20), - // Erneut die heutige Tour vom Backend laden. `LoadTour` zeigt + // Erneut die Tour des gewählten Tages vom Backend laden. `LoadTour` zeigt // währenddessen den Lade-Branch (Spinner) und landet danach wieder // hier (TourEmpty) oder in der sortierbaren Lieferungsliste. FilledButton.tonalIcon( diff --git a/lib/feature/loading/presentation/loading_overview_page.dart b/lib/feature/loading/presentation/loading_overview_page.dart index 3849d32..4a499ce 100644 --- a/lib/feature/loading/presentation/loading_overview_page.dart +++ b/lib/feature/loading/presentation/loading_overview_page.dart @@ -18,6 +18,8 @@ import 'package:hl_lieferservice/feature/loading/presentation/loading_customer_p import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart'; import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart'; import 'package:hl_lieferservice/widget/orderer_hint.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart'; +import 'package:hl_lieferservice/feature/delivery/model/tour_day_text.dart'; /// Übersichts-Ansicht für die Beladen-Phase: alle Kunden mit ihren /// Artikeln und Soll/Ist-Mengen. @@ -884,6 +886,7 @@ class _EmptyOverview extends StatelessWidget { @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; + final day = TourDayText.of(context.watch().state); return Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -896,7 +899,7 @@ class _EmptyOverview extends StatelessWidget { ), const SizedBox(height: 4), Text( - 'Für heute ist aktuell keine Tour verfügbar.', + 'Für ${day.afterFor} ist aktuell keine Tour verfügbar.', style: Theme.of(context) .textTheme .bodySmall diff --git a/lib/widget/phase_stepper/phase_stepper.dart b/lib/widget/phase_stepper/phase_stepper.dart index 985820f..fbd675a 100644 --- a/lib/widget/phase_stepper/phase_stepper.dart +++ b/lib/widget/phase_stepper/phase_stepper.dart @@ -432,7 +432,9 @@ class _SelectedDatePill extends StatelessWidget { // Standard-Fenster um heute; falls das aktuell angezeigte Datum (z. B. // ein Dev-Override in der Vergangenheit) außerhalb liegt, Fenster // erweitern, damit initialDate immer im gültigen Bereich bleibt. - final firstDefault = today.subtract(const Duration(days: 14)); + // 90 Tage zurück: vergangene Touren (z. B. zum Nachsehen oder nach + // einem Nach-Import) müssen erreichbar sein. + final firstDefault = today.subtract(const Duration(days: 90)); final lastDefault = today.add(const Duration(days: 60)); final firstDate = current.isBefore(firstDefault) ? current : firstDefault; final lastDate = current.isAfter(lastDefault) ? current : lastDefault; diff --git a/test/session_scope_test.dart b/test/session_scope_test.dart index 236ae48..ad39713 100644 --- a/test/session_scope_test.dart +++ b/test/session_scope_test.dart @@ -106,12 +106,16 @@ void main() { await send(tester, _login(423)); final fuer423 = carSelections.last; expect(payments.loads, 1); + // 423 schaut sich einen anderen Tag an … + tourDates.last.pick(DateTime(2026, 9, 2)); // Abmelden und anderer Fahrer: nichts von 423 darf übrig bleiben. await send(tester, const AuthLoggedOut()); await send(tester, _login(414)); expect(carSelections.last, isNot(same(fuer423))); expect(tourDates.last, isNot(same(tourDates.first))); + // … der nächste Fahrer startet trotzdem auf „heute". + expect(tourDates.last.state, isNull); expect(payments.loads, 2); // Auch derselbe Fahrer nach erneutem Login lädt neu. diff --git a/test/tour_date_test.dart b/test/tour_date_test.dart new file mode 100644 index 0000000..a1a1703 --- /dev/null +++ b/test/tour_date_test.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:hl_lieferservice/data/cache/attachment_cache.dart'; +import 'package:hl_lieferservice/domain/entity/tour.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/domain/repository/tour_repository.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/tour_day_text.dart'; +import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; + +/// Antwortet pro angefragtem Datum erst, wenn der Test es freigibt. +class _SlowTours implements TourRepository { + final pending = >{}; + + @override + Future getMyTourDetails({DateTime? date}) => + (pending[date] = Completer()).future; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _NoCache implements AttachmentCache { + @override + Future retainOnly(Set ids) async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +TourDetails _tourAm(DateTime date) => TourDetails( + tour: Tour(id: 't-$date', accountId: 414, date: date, syncedAt: date), + deliveries: const [], + customers: const {}, + contacts: const {}, + articles: const {}, + warehouses: const {}, + notesByDeliveryId: const {}, + creditsByDeliveryId: const {}, + paymentsByDeliveryId: const {}, + services: const [], + serviceValuesByDeliveryId: const {}, + contactSourcesByDeliveryId: const {}, + contactChannelsBySourceId: const {}, +); + +void main() { + group('TourDayText', () { + final now = DateTime(2026, 9, 25, 16, 30); + + test('ohne Auswahl und am selben Tag: heute', () { + expect(TourDayText.of(null, now: now).afterFor, 'heute'); + expect(TourDayText.of(DateTime(2026, 9, 25), now: now).when, 'heute'); + }); + + test('morgen und gestern', () { + expect(TourDayText.of(DateTime(2026, 9, 26), now: now).when, 'morgen'); + expect(TourDayText.of(DateTime(2026, 9, 24), now: now).when, 'gestern'); + }); + + test('anderer Tag mit Datum und passender Präposition', () { + final t = TourDayText.of(DateTime(2026, 9, 2), now: now); + expect(t.afterFor, 'den 02.09.2026'); + expect(t.when, 'am 02.09.2026'); + }); + }); + + test('langsame Antwort eines älteren Tages überschreibt nicht die neue Wahl', + () async { + final repo = _SlowTours(); + final bloc = TourBloc( + tourRepository: repo, + opBloc: OperationBloc(), + attachmentCache: _NoCache(), + ); + final erster = DateTime(2026, 9, 2); + final zweiter = DateTime(2026, 9, 25); + + bloc.add(LoadTour.forDate(erster)); + await Future.delayed(Duration.zero); + bloc.add(LoadTour.forDate(zweiter)); + await Future.delayed(Duration.zero); + + // Neuere Anfrage antwortet zuerst, die ältere danach. + repo.pending[zweiter]!.complete(_tourAm(zweiter)); + await Future.delayed(Duration.zero); + repo.pending[erster]!.complete(_tourAm(erster)); + await Future.delayed(Duration.zero); + + final state = bloc.state as TourLoaded; + expect(state.details.tour.date, zweiter); + await bloc.close(); + }); +}