From 7b7cb4eb63e569fb7a92790e5d945ec7f1b66d74 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Tue, 1 Sep 2026 15:42:44 +0200 Subject: [PATCH] feat(header): Datumswahl im Kopf statt Fahrzeug-Pille Neue Kalender-Pille im Phase-Stepper: zeigt das Tour-Datum und oeffnet einen Datepicker, um die Tour eines anderen Tages zu laden (z. B. morgen vorab). Fahrzeugwechsel bleibt ueber den Drawer erreichbar. - TourDateCubit haelt das gewaehlte Datum (null = heute, Server autoritativ) - LoadTour({date}) + TourBloc merkt das Datum (RefreshTour laedt denselben Tag) - Repository getMyTourDetails({date}) -> API listMyTours(date:) -> GET /me/tours?date= Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/data/repository/tour_repository_impl.dart | 19 +- lib/domain/repository/tour_repository.dart | 20 +- lib/feature/delivery/bloc/tour_bloc.dart | 14 +- .../delivery/bloc/tour_date_cubit.dart | 22 +++ lib/feature/delivery/bloc/tour_event.dart | 12 +- lib/widget/app.dart | 9 + lib/widget/phase_stepper/phase_stepper.dart | 172 ++++++++++++------ .../lib/src/api/tours_api.dart | 15 +- 8 files changed, 204 insertions(+), 79 deletions(-) create mode 100644 lib/feature/delivery/bloc/tour_date_cubit.dart diff --git a/lib/data/repository/tour_repository_impl.dart b/lib/data/repository/tour_repository_impl.dart index 9ec6c22..7091124 100644 --- a/lib/data/repository/tour_repository_impl.dart +++ b/lib/data/repository/tour_repository_impl.dart @@ -24,9 +24,10 @@ class TourRepositoryImpl implements TourRepository { final api.HolzleitnerApi _api; @override - Future getMyTourSummaryOfToday() async { + Future getMyTourSummary({DateTime? date}) async { try { - final response = await _api.getToursApi().listMyToursToday(); + final response = + await _api.getToursApi().listMyTours(date: _formatDate(date)); final tours = response.data?.tours; if (tours == null || tours.isEmpty) return null; // Backend liefert die Liste sortiert; wir nehmen die erste Tour. @@ -38,6 +39,16 @@ class TourRepositoryImpl implements TourRepository { } } + /// Formatiert das Datum als `YYYY-MM-DD` für den `?date=`-Query. `null` + /// bleibt `null` → der Server nimmt „heute". + static String? _formatDate(DateTime? date) { + if (date == null) return null; + final y = date.year.toString().padLeft(4, '0'); + final m = date.month.toString().padLeft(2, '0'); + final d = date.day.toString().padLeft(2, '0'); + return '$y-$m-$d'; + } + @override Future getTourDetails(String tourId) async { try { @@ -55,8 +66,8 @@ class TourRepositoryImpl implements TourRepository { } @override - Future getMyTourDetailsOfToday() async { - final summary = await getMyTourSummaryOfToday(); + Future getMyTourDetails({DateTime? date}) async { + final summary = await getMyTourSummary(date: date); if (summary == null) return null; return getTourDetails(summary.tourId); } diff --git a/lib/domain/repository/tour_repository.dart b/lib/domain/repository/tour_repository.dart index 991c3cf..293feda 100644 --- a/lib/domain/repository/tour_repository.dart +++ b/lib/domain/repository/tour_repository.dart @@ -17,22 +17,26 @@ import 'package:hl_lieferservice/domain/entity/tour_details.dart'; /// Account-Filter sitzt serverseitig im JWT — der Client schickt nie eine /// `personalnummer`/`accountId` mit. abstract interface class TourRepository { - /// Die heutige Tour-Übersicht des angemeldeten Fahrers oder `null`, - /// wenn keine Tour für heute angelegt ist (ERP-Sync noch nicht - /// gelaufen, Treiber-Urlaub etc.). + /// Die Tour-Übersicht des angemeldeten Fahrers für [date] (oder „heute", + /// wenn `null`) — bzw. `null`, wenn für den Tag keine Tour angelegt ist + /// (ERP-Sync noch nicht gelaufen, Fahrer-Urlaub etc.). + /// + /// Ohne [date] entscheidet der Server über „heute" (autoritativ). Ist + /// [date] gesetzt (Fahrer wählt es im Kopf-Kalender), wird dieses Datum + /// angefragt. /// /// Liefert nur die schlanke `TourSummary`-Repräsentation; /// [getTourDetails] zieht dann den vollen Aggregat-Snapshot. - Future getMyTourSummaryOfToday(); + Future getMyTourSummary({DateTime? date}); /// Lädt das volle Tour-Aggregat (Tour + Lieferungen + Items + /// Stammdaten + Notizen) für die gegebene Tour-Id. Future getTourDetails(String tourId); - /// Convenience: kombiniert [getMyTourSummaryOfToday] + [getTourDetails] - /// und gibt `null` zurück, wenn keine Tour existiert. Verwendet die App - /// beim Initial-Load. - Future getMyTourDetailsOfToday(); + /// Convenience: kombiniert [getMyTourSummary] + [getTourDetails] und gibt + /// `null` zurück, wenn für [date] (Default „heute") keine Tour existiert. + /// Verwendet die App beim Initial-Load und beim Datumswechsel. + Future getMyTourDetails({DateTime? date}); /// Schreibt die Sortier-Reihenfolge der Lieferungen einer Tour neu. /// diff --git a/lib/feature/delivery/bloc/tour_bloc.dart b/lib/feature/delivery/bloc/tour_bloc.dart index cdfc9b8..bcf5086 100644 --- a/lib/feature/delivery/bloc/tour_bloc.dart +++ b/lib/feature/delivery/bloc/tour_bloc.dart @@ -65,6 +65,11 @@ class TourBloc extends Bloc { final Uuid _uuid = const Uuid(); + /// Aktuell angefragtes Tour-Datum. `null` = „heute" (Server entscheidet). + /// Wird vom [LoadTour]-Event gesetzt und von [RefreshTour] wiederverwendet, + /// damit ein Pull-to-refresh denselben Tag neu lädt (nicht heute). + DateTime? _selectedDate; + /// 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. @@ -75,9 +80,10 @@ class TourBloc extends Bloc { // ─── LoadTour ──────────────────────────────────────────────────────── Future _onLoad(LoadTour event, Emitter emit) async { + _selectedDate = event.date; emit(const TourLoading()); try { - final details = await tourRepository.getMyTourDetailsOfToday(); + final details = await tourRepository.getMyTourDetails(date: _selectedDate); if (details == null) { emit(const TourEmpty()); return; @@ -98,14 +104,14 @@ class TourBloc extends Bloc { final current = state; if (current is! TourLoaded) { // Keine sichtbare Tour zum „weichen" Refreshen — wie ein LoadTour - // behandeln. - await _onLoad(const LoadTour(), emit); + // behandeln. Das aktuell gewählte Datum beibehalten. + await _onLoad(LoadTour(date: _selectedDate), emit); return; } emit(current.copyWith(isRefreshing: true, refreshError: null)); try { - final details = await tourRepository.getMyTourDetailsOfToday(); + final details = await tourRepository.getMyTourDetails(date: _selectedDate); if (details == null) { emit(const TourEmpty()); return; diff --git a/lib/feature/delivery/bloc/tour_date_cubit.dart b/lib/feature/delivery/bloc/tour_date_cubit.dart new file mode 100644 index 0000000..728b3d2 --- /dev/null +++ b/lib/feature/delivery/bloc/tour_date_cubit.dart @@ -0,0 +1,22 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Hält das im Kopf-Kalender aktiv gewählte Tour-Datum. +/// +/// State `null` bedeutet **„heute"** — und zwar autoritativ vom Server +/// entschieden (die App-Uhr ist nicht vertrauenswürdig; siehe Backend +/// `ListMyToursTodayUseCase`). Erst wenn der Fahrer im Header bewusst ein +/// Datum wählt, trägt der Cubit ein konkretes Datum. +/// +/// Bewusst getrennt vom `TourBloc`: das gewählte Datum muss auch dann bekannt +/// sein, wenn für den Tag gar keine Tour existiert (`TourEmpty` trägt kein +/// `tour.date`), damit der Header trotzdem das richtige Datum anzeigt. +class TourDateCubit extends Cubit { + TourDateCubit() : super(null); + + /// Wählt ein konkretes Datum. Nur die Tageskomponente zählt — Uhrzeit wird + /// verworfen, damit Vergleiche/Anzeige stabil bleiben. + void pick(DateTime date) => emit(DateTime(date.year, date.month, date.day)); + + /// Zurück auf „heute" (Server-Default). + void reset() => emit(null); +} diff --git a/lib/feature/delivery/bloc/tour_event.dart b/lib/feature/delivery/bloc/tour_event.dart index af676ff..c12aae8 100644 --- a/lib/feature/delivery/bloc/tour_event.dart +++ b/lib/feature/delivery/bloc/tour_event.dart @@ -7,10 +7,16 @@ sealed class TourEvent { const TourEvent(); } -/// Initial-Load der heutigen Tour des angemeldeten Fahrers. -/// Account-Filter sitzt im JWT — kein zusätzliches Argument nötig. +/// Initial-Load bzw. Laden der Tour des angemeldeten Fahrers für ein +/// bestimmtes Datum. Account-Filter sitzt im JWT. +/// +/// [date] `null` = „heute" (Server entscheidet). Wählt der Fahrer im +/// Kopf-Kalender ein Datum, wird es hier übergeben und der Bloc merkt es +/// sich für nachfolgende [RefreshTour]-Events. class LoadTour extends TourEvent { - const LoadTour(); + const LoadTour({this.date}); + + final DateTime? date; } /// Pull-to-refresh / manueller Reload aus der Übersicht. Vorhandener diff --git a/lib/widget/app.dart b/lib/widget/app.dart index fe049f1..c27ce3e 100644 --- a/lib/widget/app.dart +++ b/lib/widget/app.dart @@ -20,6 +20,7 @@ import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi; import 'package:hl_lieferservice/data/repository/tour_repository_impl.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; @@ -68,6 +69,11 @@ class _DeliveryAppState extends State { attachmentCache: locator(), ), ), + BlocProvider( + // Hält das im Header gewählte Tour-Datum (null = heute). + // Steuert, welchen Tag der TourBloc lädt. + create: (context) => TourDateCubit(), + ), BlocProvider( create: (context) => CarSelectBloc(repository: CarSelectionRepository()), @@ -121,6 +127,7 @@ class _DeliveryAppState extends State { ], child: MaterialApp( title: 'Holzleitner Auslieferung', + debugShowCheckedModeBanner: false, // Wrap the Navigator (not just the home route) so the loading // overlay covers every pushed route — DeliveryDetail, Cars, // dialogs, etc. — not only the initial home tree. @@ -155,6 +162,7 @@ class _DeliveryAppState extends State { if (state is AppConfigLoadingFailed) { return MaterialApp( title: 'Holzleitner Auslieferung', + debugShowCheckedModeBanner: false, home: Scaffold( body: Center(child: Text("Fehler beim Laden der Konfiguration")), ), @@ -163,6 +171,7 @@ class _DeliveryAppState extends State { return MaterialApp( title: 'Holzleitner Auslieferung', + debugShowCheckedModeBanner: false, home: Scaffold( body: Center(child: const CircularProgressIndicator()), ), diff --git a/lib/widget/phase_stepper/phase_stepper.dart b/lib/widget/phase_stepper/phase_stepper.dart index a4ec463..4c8d1ae 100644 --- a/lib/widget/phase_stepper/phase_stepper.dart +++ b/lib/widget/phase_stepper/phase_stepper.dart @@ -1,14 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; -import 'package:hl_lieferservice/feature/car_selection/bloc/events.dart'; -import 'package:hl_lieferservice/feature/car_selection/bloc/state.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/phase_state.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.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'; @@ -153,17 +151,9 @@ class PhaseStepper extends StatelessWidget { ), _ReloadButton(onPrimary: onPrimary), const Spacer(), - BlocBuilder( - builder: (context, state) { - if (state is! CarSelectComplete) { - return const SizedBox.shrink(); - } - return _SelectedCarPill( - plate: state.selectedCar.plate, - onPrimary: onPrimary, - ); - }, - ), + // Kopf-Kalender: ersetzt die frühere Fahrzeug-Pille. + // Der Fahrzeugwechsel bleibt über den Drawer erreichbar. + _SelectedDatePill(onPrimary: onPrimary), ], ), Padding( @@ -332,57 +322,127 @@ class _StepperItem extends StatelessWidget { /// dem Primary-Header, kompakter IconButton — der Plate-Text bleibt /// dominantes Element, der Wechseln-Knopf ist eine sekundäre Geste, die /// erst auf Anfrage benutzt wird. -class _SelectedCarPill extends StatelessWidget { - const _SelectedCarPill({required this.plate, required this.onPrimary}); +/// Kopf-Kalender-Pille: zeigt das aktuell angezeigte Tour-Datum und öffnet +/// bei Tipp einen Datepicker, um die Tour eines anderen Tages zu laden +/// (z. B. die von morgen vorab). Ersetzt die frühere Fahrzeug-Pille; der +/// Fahrzeugwechsel läuft jetzt über den Drawer. +/// +/// Das angezeigte Datum ist das im [TourDateCubit] gewählte; ist keins +/// gewählt (`null` = „heute"), fällt es auf das Datum der geladenen Tour +/// zurück (autoritativ vom Server) und zur Not auf die Geräte-Uhr. +class _SelectedDatePill extends StatelessWidget { + const _SelectedDatePill({required this.onPrimary}); - final String plate; final Color onPrimary; @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.only(left: 10, right: 2), - decoration: BoxDecoration( - color: onPrimary.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.local_shipping, color: onPrimary, size: 16), - const SizedBox(width: 6), - Text( - plate, - style: TextStyle( - color: onPrimary, - fontWeight: FontWeight.bold, - fontSize: 14, - ), + final selected = context.watch().state; + final tourState = context.watch().state; + final loaded = tourState is TourLoaded ? tourState.details.tour.date : null; + + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final display = selected ?? + (loaded != null + ? DateTime(loaded.year, loaded.month, loaded.day) + : today); + + final relative = _relativeLabel(display, today); + + return Material( + color: onPrimary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _pickDate(context, display, today), + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 5, 8, 5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.event, color: onPrimary, size: 16), + const SizedBox(width: 6), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (relative != null) + Text( + relative, + style: TextStyle( + color: onPrimary.withValues(alpha: 0.85), + fontSize: 10, + height: 1.0, + fontWeight: FontWeight.w600, + ), + ), + Text( + _fmt(display), + style: TextStyle( + color: onPrimary, + fontWeight: FontWeight.bold, + fontSize: 14, + height: 1.15, + ), + ), + ], + ), + const SizedBox(width: 2), + Icon( + Icons.expand_more, + color: onPrimary.withValues(alpha: 0.85), + size: 18, + ), + ], ), - const SizedBox(width: 2), - IconButton( - tooltip: 'Fahrzeug wechseln', - icon: Icon( - Icons.swap_horiz_rounded, - // Etwas heller als das Plate, damit der Button als sekundäre - // Aktion gelesen wird und das Plate nicht überstrahlt. - color: onPrimary.withValues(alpha: 0.85), - size: 18, - ), - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.all(4), - constraints: const BoxConstraints( - minWidth: 32, - minHeight: 32, - ), - splashRadius: 18, - onPressed: () => - context.read().add(CarSelectChange()), - ), - ], + ), ), ); } + + /// „Heute"/„Morgen"/„Gestern" relativ zur Geräte-Uhr, sonst `null`. + /// Bewusst ohne Locale-Abhängigkeit (kein `intl`-Init nötig). + static String? _relativeLabel(DateTime d, DateTime today) { + final diff = DateTime(d.year, d.month, d.day).difference(today).inDays; + if (diff == 0) return 'Heute'; + if (diff == 1) return 'Morgen'; + if (diff == -1) return 'Gestern'; + return null; + } + + static String _fmt(DateTime d) { + final dd = d.day.toString().padLeft(2, '0'); + final mm = d.month.toString().padLeft(2, '0'); + return '$dd.$mm.${d.year}'; + } + + Future _pickDate( + BuildContext context, + DateTime current, + DateTime today, + ) async { + // 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)); + final lastDefault = today.add(const Duration(days: 60)); + final firstDate = current.isBefore(firstDefault) ? current : firstDefault; + final lastDate = current.isAfter(lastDefault) ? current : lastDefault; + + final picked = await showDatePicker( + context: context, + initialDate: current, + firstDate: firstDate, + lastDate: lastDate, + helpText: 'Tour-Datum wählen', + cancelText: 'Abbrechen', + confirmText: 'Anzeigen', + ); + if (picked == null || !context.mounted) return; + context.read().pick(picked); + context.read().add(LoadTour(date: picked)); + } } class _Connector extends StatelessWidget { diff --git a/packages/holzleitner_api/lib/src/api/tours_api.dart b/packages/holzleitner_api/lib/src/api/tours_api.dart index e1958cf..8a9deba 100644 --- a/packages/holzleitner_api/lib/src/api/tours_api.dart +++ b/packages/holzleitner_api/lib/src/api/tours_api.dart @@ -103,10 +103,11 @@ class ToursApi { ); } - /// Listet heutige Touren des angemeldeten Fahrers (Filter aus dem JWT). - /// + /// Listet die Touren des angemeldeten Fahrers (Filter aus dem JWT) für ein + /// Ziel-Datum. Ohne [date] gilt serverseitig „heute". /// /// Parameters: + /// * [date] - Ziel-Tourdatum im Format YYYY-MM-DD (Default: heute) /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation /// * [headers] - Can be used to add additional headers to the request /// * [extras] - Can be used to add flags to the request @@ -116,7 +117,8 @@ class ToursApi { /// /// Returns a [Future] containing a [Response] with a [TourSummaryList] as data /// Throws [DioException] if API call or serialization fails - Future> listMyToursToday({ + Future> listMyTours({ + String? date, CancelToken? cancelToken, Map? headers, Map? extra, @@ -124,7 +126,7 @@ class ToursApi { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final _path = r'/me/tours/today'; + final _path = r'/me/tours'; final _options = Options( method: r'GET', headers: { @@ -143,9 +145,14 @@ class ToursApi { validateStatus: validateStatus, ); + final _queryParameters = { + if (date != null) r'date': date, + }; + final _response = await _dio.request( _path, options: _options, + queryParameters: _queryParameters, cancelToken: cancelToken, onSendProgress: onSendProgress, onReceiveProgress: onReceiveProgress,