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) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-01 15:42:44 +02:00
parent 7324aa4e7d
commit 7b7cb4eb63
8 changed files with 204 additions and 79 deletions

View File

@ -24,9 +24,10 @@ class TourRepositoryImpl implements TourRepository {
final api.HolzleitnerApi _api; final api.HolzleitnerApi _api;
@override @override
Future<TourSummary?> getMyTourSummaryOfToday() async { Future<TourSummary?> getMyTourSummary({DateTime? date}) async {
try { try {
final response = await _api.getToursApi().listMyToursToday(); final response =
await _api.getToursApi().listMyTours(date: _formatDate(date));
final tours = response.data?.tours; final tours = response.data?.tours;
if (tours == null || tours.isEmpty) return null; if (tours == null || tours.isEmpty) return null;
// Backend liefert die Liste sortiert; wir nehmen die erste Tour. // 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 @override
Future<TourDetails> getTourDetails(String tourId) async { Future<TourDetails> getTourDetails(String tourId) async {
try { try {
@ -55,8 +66,8 @@ class TourRepositoryImpl implements TourRepository {
} }
@override @override
Future<TourDetails?> getMyTourDetailsOfToday() async { Future<TourDetails?> getMyTourDetails({DateTime? date}) async {
final summary = await getMyTourSummaryOfToday(); final summary = await getMyTourSummary(date: date);
if (summary == null) return null; if (summary == null) return null;
return getTourDetails(summary.tourId); return getTourDetails(summary.tourId);
} }

View File

@ -17,22 +17,26 @@ import 'package:hl_lieferservice/domain/entity/tour_details.dart';
/// Account-Filter sitzt serverseitig im JWT — der Client schickt nie eine /// Account-Filter sitzt serverseitig im JWT — der Client schickt nie eine
/// `personalnummer`/`accountId` mit. /// `personalnummer`/`accountId` mit.
abstract interface class TourRepository { abstract interface class TourRepository {
/// Die heutige Tour-Übersicht des angemeldeten Fahrers oder `null`, /// Die Tour-Übersicht des angemeldeten Fahrers für [date] (oder „heute",
/// wenn keine Tour für heute angelegt ist (ERP-Sync noch nicht /// wenn `null`) — bzw. `null`, wenn für den Tag keine Tour angelegt ist
/// gelaufen, Treiber-Urlaub etc.). /// (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; /// Liefert nur die schlanke `TourSummary`-Repräsentation;
/// [getTourDetails] zieht dann den vollen Aggregat-Snapshot. /// [getTourDetails] zieht dann den vollen Aggregat-Snapshot.
Future<TourSummary?> getMyTourSummaryOfToday(); Future<TourSummary?> getMyTourSummary({DateTime? date});
/// Lädt das volle Tour-Aggregat (Tour + Lieferungen + Items + /// Lädt das volle Tour-Aggregat (Tour + Lieferungen + Items +
/// Stammdaten + Notizen) für die gegebene Tour-Id. /// Stammdaten + Notizen) für die gegebene Tour-Id.
Future<TourDetails> getTourDetails(String tourId); Future<TourDetails> getTourDetails(String tourId);
/// Convenience: kombiniert [getMyTourSummaryOfToday] + [getTourDetails] /// Convenience: kombiniert [getMyTourSummary] + [getTourDetails] und gibt
/// und gibt `null` zurück, wenn keine Tour existiert. Verwendet die App /// `null` zurück, wenn für [date] (Default „heute") keine Tour existiert.
/// beim Initial-Load. /// Verwendet die App beim Initial-Load und beim Datumswechsel.
Future<TourDetails?> getMyTourDetailsOfToday(); Future<TourDetails?> getMyTourDetails({DateTime? date});
/// Schreibt die Sortier-Reihenfolge der Lieferungen einer Tour neu. /// Schreibt die Sortier-Reihenfolge der Lieferungen einer Tour neu.
/// ///

View File

@ -65,6 +65,11 @@ class TourBloc extends Bloc<TourEvent, TourState> {
final Uuid _uuid = const Uuid(); 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. /// Räumt verwaiste Cache-Bilder weg, sobald frische Tour-Daten vorliegen.
/// Fire-and-forget — Cache-Pflege darf den Load nie verzögern oder /// Fire-and-forget — Cache-Pflege darf den Load nie verzögern oder
/// scheitern lassen. /// scheitern lassen.
@ -75,9 +80,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
// ─── LoadTour ──────────────────────────────────────────────────────── // ─── LoadTour ────────────────────────────────────────────────────────
Future<void> _onLoad(LoadTour event, Emitter<TourState> emit) async { Future<void> _onLoad(LoadTour event, Emitter<TourState> emit) async {
_selectedDate = event.date;
emit(const TourLoading()); emit(const TourLoading());
try { try {
final details = await tourRepository.getMyTourDetailsOfToday(); final details = await tourRepository.getMyTourDetails(date: _selectedDate);
if (details == null) { if (details == null) {
emit(const TourEmpty()); emit(const TourEmpty());
return; return;
@ -98,14 +104,14 @@ class TourBloc extends Bloc<TourEvent, TourState> {
final current = state; final current = state;
if (current is! TourLoaded) { if (current is! TourLoaded) {
// Keine sichtbare Tour zum „weichen" Refreshen — wie ein LoadTour // Keine sichtbare Tour zum „weichen" Refreshen — wie ein LoadTour
// behandeln. // behandeln. Das aktuell gewählte Datum beibehalten.
await _onLoad(const LoadTour(), emit); await _onLoad(LoadTour(date: _selectedDate), emit);
return; return;
} }
emit(current.copyWith(isRefreshing: true, refreshError: null)); emit(current.copyWith(isRefreshing: true, refreshError: null));
try { try {
final details = await tourRepository.getMyTourDetailsOfToday(); final details = await tourRepository.getMyTourDetails(date: _selectedDate);
if (details == null) { if (details == null) {
emit(const TourEmpty()); emit(const TourEmpty());
return; return;

View File

@ -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<DateTime?> {
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);
}

View File

@ -7,10 +7,16 @@ sealed class TourEvent {
const TourEvent(); const TourEvent();
} }
/// Initial-Load der heutigen Tour des angemeldeten Fahrers. /// Initial-Load bzw. Laden der Tour des angemeldeten Fahrers für ein
/// Account-Filter sitzt im JWT — kein zusätzliches Argument nötig. /// 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 { class LoadTour extends TourEvent {
const LoadTour(); const LoadTour({this.date});
final DateTime? date;
} }
/// Pull-to-refresh / manueller Reload aus der Übersicht. Vorhandener /// Pull-to-refresh / manueller Reload aus der Übersicht. Vorhandener

View File

@ -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/data/repository/tour_repository_impl.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.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_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/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart'; import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
@ -68,6 +69,11 @@ class _DeliveryAppState extends State<DeliveryApp> {
attachmentCache: locator<AttachmentCache>(), attachmentCache: locator<AttachmentCache>(),
), ),
), ),
BlocProvider(
// Hält das im Header gewählte Tour-Datum (null = heute).
// Steuert, welchen Tag der TourBloc lädt.
create: (context) => TourDateCubit(),
),
BlocProvider( BlocProvider(
create: (context) => create: (context) =>
CarSelectBloc(repository: CarSelectionRepository()), CarSelectBloc(repository: CarSelectionRepository()),
@ -121,6 +127,7 @@ class _DeliveryAppState extends State<DeliveryApp> {
], ],
child: MaterialApp( child: MaterialApp(
title: 'Holzleitner Auslieferung', title: 'Holzleitner Auslieferung',
debugShowCheckedModeBanner: false,
// Wrap the Navigator (not just the home route) so the loading // Wrap the Navigator (not just the home route) so the loading
// overlay covers every pushed route — DeliveryDetail, Cars, // overlay covers every pushed route — DeliveryDetail, Cars,
// dialogs, etc. — not only the initial home tree. // dialogs, etc. — not only the initial home tree.
@ -155,6 +162,7 @@ class _DeliveryAppState extends State<DeliveryApp> {
if (state is AppConfigLoadingFailed) { if (state is AppConfigLoadingFailed) {
return MaterialApp( return MaterialApp(
title: 'Holzleitner Auslieferung', title: 'Holzleitner Auslieferung',
debugShowCheckedModeBanner: false,
home: Scaffold( home: Scaffold(
body: Center(child: Text("Fehler beim Laden der Konfiguration")), body: Center(child: Text("Fehler beim Laden der Konfiguration")),
), ),
@ -163,6 +171,7 @@ class _DeliveryAppState extends State<DeliveryApp> {
return MaterialApp( return MaterialApp(
title: 'Holzleitner Auslieferung', title: 'Holzleitner Auslieferung',
debugShowCheckedModeBanner: false,
home: Scaffold( home: Scaffold(
body: Center(child: const CircularProgressIndicator()), body: Center(child: const CircularProgressIndicator()),
), ),

View File

@ -1,14 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.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_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.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/phase_state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_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_event.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/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart'; import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
@ -153,17 +151,9 @@ class PhaseStepper extends StatelessWidget {
), ),
_ReloadButton(onPrimary: onPrimary), _ReloadButton(onPrimary: onPrimary),
const Spacer(), const Spacer(),
BlocBuilder<CarSelectBloc, CarSelectState>( // Kopf-Kalender: ersetzt die frühere Fahrzeug-Pille.
builder: (context, state) { // Der Fahrzeugwechsel bleibt über den Drawer erreichbar.
if (state is! CarSelectComplete) { _SelectedDatePill(onPrimary: onPrimary),
return const SizedBox.shrink();
}
return _SelectedCarPill(
plate: state.selectedCar.plate,
onPrimary: onPrimary,
);
},
),
], ],
), ),
Padding( Padding(
@ -332,57 +322,127 @@ class _StepperItem extends StatelessWidget {
/// dem Primary-Header, kompakter IconButton — der Plate-Text bleibt /// dem Primary-Header, kompakter IconButton — der Plate-Text bleibt
/// dominantes Element, der Wechseln-Knopf ist eine sekundäre Geste, die /// dominantes Element, der Wechseln-Knopf ist eine sekundäre Geste, die
/// erst auf Anfrage benutzt wird. /// erst auf Anfrage benutzt wird.
class _SelectedCarPill extends StatelessWidget { /// Kopf-Kalender-Pille: zeigt das aktuell angezeigte Tour-Datum und öffnet
const _SelectedCarPill({required this.plate, required this.onPrimary}); /// 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; final Color onPrimary;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( final selected = context.watch<TourDateCubit>().state;
padding: const EdgeInsets.only(left: 10, right: 2), final tourState = context.watch<TourBloc>().state;
decoration: BoxDecoration( 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), color: onPrimary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20), 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( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.local_shipping, color: onPrimary, size: 16), Icon(Icons.event, color: onPrimary, size: 16),
const SizedBox(width: 6), const SizedBox(width: 6),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (relative != null)
Text( Text(
plate, relative,
style: TextStyle(
color: onPrimary.withValues(alpha: 0.85),
fontSize: 10,
height: 1.0,
fontWeight: FontWeight.w600,
),
),
Text(
_fmt(display),
style: TextStyle( style: TextStyle(
color: onPrimary, color: onPrimary,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 14, fontSize: 14,
height: 1.15,
), ),
), ),
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<CarSelectBloc>().add(CarSelectChange()),
),
], ],
), ),
const SizedBox(width: 2),
Icon(
Icons.expand_more,
color: onPrimary.withValues(alpha: 0.85),
size: 18,
),
],
),
),
),
); );
} }
/// „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<void> _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<TourDateCubit>().pick(picked);
context.read<TourBloc>().add(LoadTour(date: picked));
}
} }
class _Connector extends StatelessWidget { class _Connector extends StatelessWidget {

View File

@ -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: /// Parameters:
/// * [date] - Ziel-Tourdatum im Format YYYY-MM-DD (Default: heute)
/// * [cancelToken] - A [CancelToken] that can be used to cancel the operation /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation
/// * [headers] - Can be used to add additional headers to the request /// * [headers] - Can be used to add additional headers to the request
/// * [extras] - Can be used to add flags 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 /// Returns a [Future] containing a [Response] with a [TourSummaryList] as data
/// Throws [DioException] if API call or serialization fails /// Throws [DioException] if API call or serialization fails
Future<Response<TourSummaryList>> listMyToursToday({ Future<Response<TourSummaryList>> listMyTours({
String? date,
CancelToken? cancelToken, CancelToken? cancelToken,
Map<String, dynamic>? headers, Map<String, dynamic>? headers,
Map<String, dynamic>? extra, Map<String, dynamic>? extra,
@ -124,7 +126,7 @@ class ToursApi {
ProgressCallback? onSendProgress, ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress, ProgressCallback? onReceiveProgress,
}) async { }) async {
final _path = r'/me/tours/today'; final _path = r'/me/tours';
final _options = Options( final _options = Options(
method: r'GET', method: r'GET',
headers: <String, dynamic>{ headers: <String, dynamic>{
@ -143,9 +145,14 @@ class ToursApi {
validateStatus: validateStatus, validateStatus: validateStatus,
); );
final _queryParameters = <String, dynamic>{
if (date != null) r'date': date,
};
final _response = await _dio.request<Object>( final _response = await _dio.request<Object>(
_path, _path,
options: _options, options: _options,
queryParameters: _queryParameters,
cancelToken: cancelToken, cancelToken: cancelToken,
onSendProgress: onSendProgress, onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress, onReceiveProgress: onReceiveProgress,