fix: Tour-Datum — Leer-Texte, veraltete Antworten, Kalenderfenster
- 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 <noreply@anthropic.com>
This commit is contained in:
@ -72,6 +72,12 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
/// 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<TourEvent, TourState> {
|
||||
// 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<TourEvent, TourState> {
|
||||
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<TourEvent, TourState> {
|
||||
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<TourEvent, TourState> {
|
||||
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
|
||||
|
||||
40
lib/feature/delivery/model/tour_day_text.dart
Normal file
40
lib/feature/delivery/model/tour_day_text.dart
Normal file
@ -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}';
|
||||
}
|
||||
@ -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<TourBloc>().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<TourDateCubit>().state).afterFor} '
|
||||
'ist keine Tour zugewiesen.\n'
|
||||
'Zum Aktualisieren nach unten ziehen.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16),
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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<DeliverySelectionPage> {
|
||||
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<TourDateCubit>().state).afterFor} '
|
||||
'ist keine Tour zugewiesen.',
|
||||
style: const TextStyle(fontSize: 16),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
@ -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<DeliverySortPage> {
|
||||
}
|
||||
|
||||
Widget _emptyState() {
|
||||
final day = TourDayText.of(context.watch<TourDateCubit>().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(
|
||||
|
||||
@ -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<TourDateCubit>().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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.
|
||||
|
||||
98
test/tour_date_test.dart
Normal file
98
test/tour_date_test.dart
Normal file
@ -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 = <DateTime?, Completer<TourDetails?>>{};
|
||||
|
||||
@override
|
||||
Future<TourDetails?> getMyTourDetails({DateTime? date}) =>
|
||||
(pending[date] = Completer<TourDetails?>()).future;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _NoCache implements AttachmentCache {
|
||||
@override
|
||||
Future<void> retainOnly(Set<String> 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<void>.delayed(Duration.zero);
|
||||
bloc.add(LoadTour.forDate(zweiter));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
// Neuere Anfrage antwortet zuerst, die ältere danach.
|
||||
repo.pending[zweiter]!.complete(_tourAm(zweiter));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
repo.pending[erster]!.complete(_tourAm(erster));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final state = bloc.state as TourLoaded;
|
||||
expect(state.details.tour.date, zweiter);
|
||||
await bloc.close();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user