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:
Dennis Nemec
2026-09-25 17:04:56 +02:00
parent a5215367fd
commit 2ff331294e
9 changed files with 188 additions and 19 deletions

View File

@ -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

View 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}';
}

View File

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

View File

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

View File

@ -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(

View File

@ -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

View File

@ -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;