From 2470299a1003538f1f667bfab8a6219cee38ff86 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Tue, 28 Apr 2026 13:03:09 +0200 Subject: [PATCH] BIG FAT --- CLAUDE.md | 31 + docs/finish_delivery.md | 85 ++ ios/Flutter/AppFrameworkInfo.plist | 2 - ios/Runner.xcodeproj/project.pbxproj | 4 +- ios/Runner/AppDelegate.swift | 7 +- ios/Runner/Info.plist | 71 +- lib/dto/customer.dart | 3 +- lib/dto/customer.g.dart | 7 +- lib/dto/delivery_update.dart | 5 +- .../authentication/bloc/auth_bloc.dart | 18 +- .../authentication/bloc/auth_event.dart | 4 +- .../authentication/bloc/auth_state.dart | 6 +- .../presentation/login_enforcer.dart | 3 +- .../presentation/login_page.dart | 27 +- lib/feature/car_selection/bloc/bloc.dart | 88 ++ lib/feature/car_selection/bloc/events.dart | 25 + lib/feature/car_selection/bloc/state.dart | 25 + .../presentation/car_selection_card.dart | 59 ++ .../presentation/car_selection_enforcer.dart | 82 ++ .../presentation/car_selection_page.dart | 232 ++++ .../presentation/selected_car_bar.dart | 56 + .../repository/car_selection_repository.dart | 34 + lib/feature/cars/bloc/cars_bloc.dart | 30 +- lib/feature/cars/bloc/cars_event.dart | 6 +- lib/feature/cars/model/selection.dart | 14 + lib/feature/cars/presentation/car_card.dart | 31 +- .../cars/presentation/car_management.dart | 59 +- .../presentation/car_management_page.dart | 69 +- lib/feature/delivery/bloc/tour_bloc.dart | 181 +--- .../delivery/detail/bloc/note_bloc.dart | 77 +- .../detail/presentation/steps/step_info.dart | 293 +++--- .../detail/service/notes_service.dart | 8 +- .../overview/presentation/delivery_info.dart | 116 +- .../overview/presentation/delivery_item.dart | 161 ++- .../overview/presentation/delivery_list.dart | 97 +- .../presentation/delivery_overview.dart | 77 +- .../presentation/delivery_overview_page.dart | 62 +- .../delivery/repository/tour_repository.dart | 2 + .../delivery/service/tour_service.dart | 19 +- lib/feature/scan/presentation/scan_page.dart | 995 +++++++++++++----- .../scan/presentation/scan_screen.dart | 472 --------- lib/feature/scan/presentation/scanner.dart | 5 +- .../settings/presentation/settings_page.dart | 7 +- lib/model/customer.dart | 9 +- lib/model/tour.dart | 17 + lib/widget/app.dart | 22 +- lib/widget/home/presentation/home.dart | 32 +- .../presentation/navigation_bar.dart | 5 + .../operations/bloc/operation_bloc.dart | 15 +- .../operations/bloc/operation_event.dart | 4 +- .../operations/bloc/operation_state.dart | 4 +- .../presentation/operation_view_enforcer.dart | 59 +- pubspec.lock | 20 +- 53 files changed, 2409 insertions(+), 1433 deletions(-) create mode 100644 docs/finish_delivery.md create mode 100644 lib/feature/car_selection/presentation/car_selection_card.dart create mode 100644 lib/feature/car_selection/presentation/car_selection_enforcer.dart create mode 100644 lib/feature/car_selection/presentation/car_selection_page.dart create mode 100644 lib/feature/car_selection/presentation/selected_car_bar.dart create mode 100644 lib/feature/car_selection/repository/car_selection_repository.dart delete mode 100644 lib/feature/scan/presentation/scan_screen.dart diff --git a/CLAUDE.md b/CLAUDE.md index e69de29..9e437df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -0,0 +1,31 @@ +# Introduction to your profile +You are a senior software engineer who has specialized in developing +Apps. You are educated on clean code and loves it! Furthermore, you want +to have good and clean software abstraction. Your focus is also on +the product itself to understand the customer's need. You have several +years experience in Flutter Development. + +# Introduction to the app +This app is made for the company "Holzleitner GmbH" in Germany. This company +is a seller for electronics such as dishwasher, fridges, oven, etc. +The goal of the app is according to the CEO, to digitalize the logistics of that company. +The company has several delivery drivers under contract. The delivery driver are the user of that app. +They should be able to track today's deliveries, add notes to specific deliveries, +sign the completion of a delivery, add a refund (ger. "Gutschrift") to the delivery. +One delivery contracter MAY have multiple delivery driver. Each contracter has one account for the app. +So, multiple drivers use the same account and SHOULD not interfere with each other. The contractor +can manage its cars in the app. + +The app should have multiple phases: +1. Car selection of the today's car of the driver +2. Loading phase. For the selected car, the barcodes of the goods are scanned and assigned to that car. +3. Delivery Phase. The drivers now see the current deliveries for today. + +# Architecture +If you get asked for changing the app, first analyze the architecture of the app +by analyzing the given code files. + +# How you need to behave if I ask you something? +If I give you a specific task (such as adding features, removing bad code smell, etc.) you have to +first analyze the code. Find the specific points in code that are potentially effected by my task. +Justify every step you make. Validate if your step is good or bad. Print a decision table. \ No newline at end of file diff --git a/docs/finish_delivery.md b/docs/finish_delivery.md new file mode 100644 index 0000000..783b3e3 --- /dev/null +++ b/docs/finish_delivery.md @@ -0,0 +1,85 @@ +# Lieferungs-Abschluss: Ablauf & bekannte Themen + +Dieses Dokument beschreibt den Request-Ablauf, der ausgelöst wird, wenn der +Fahrer nach der Unterschrift "Lieferung abschließen" auslöst, sowie bekannte +Schwachstellen, die mittelfristig adressiert werden sollten. + +Stand: 2026-04-26. + +## Aufrufkette + +UI: `SignatureView.onSigned` → `_onSign(customer, driver)` +(`lib/feature/delivery/detail/presentation/delivery_detail_page.dart`) + +BLoC: dispatched `FinishDeliveryEvent` → `TourBloc._finishDelivery` +(`lib/feature/delivery/bloc/tour_bloc.dart`) + +Repository (`lib/feature/delivery/repository/tour_repository.dart`): + +1. `uploadDriverSignature(deliveryId, driverSignature)` +2. `uploadCustomerSignature(deliveryId, customerSignature)` +3. `finishDelivery(deliveryId)` + +Daraus ergeben sich 7 sequenzielle HTTP-Requests (jeweils `await`): + +| Reihenfolge | HTTP | Endpoint | Zweck | +| ----------- | ------ | --------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| 1 | GET | `/v1/uploadFile` | `uploadId` für Fahrer-Signatur holen | +| 2 | POST | `/v1/uploadFile/{uploadId}` | Multipart-Upload `delivery_{id}_signature_driver.jpg` | +| 3 | PATCH | `/v1/uploadFile/{uploadId}` | Upload commit, liefert `ObjectID` | +| 4 | GET | `/v1/uploadFile` | `uploadId` für Kunden-Signatur holen | +| 5 | POST | `/v1/uploadFile/{uploadId}` | Multipart-Upload `delivery_{id}_signature_customer.jpg` | +| 6 | PATCH | `/v1/uploadFile/{uploadId}` | Upload commit | +| 7 | POST | `_web_finishDelivery` | Atomarer Abschluss: setzt `_SV_DELIVERY_STATE = "geliefert"` und `_SV_DELIVERY_DELIVERED_AT = `, räumt entfernte Artikel auf. Body `{ "delivery_id": , "delivered_at": "" }` | + +Die GET/POST/PATCH-Sequenz pro Signatur (Schritte 1–3 bzw. 4–6) ist vom +ERP-/Dokumentenverwaltungssystem so vorgegeben und wird hier **nicht** +angepasst. + +Der frühere zusätzliche Aufruf von `_web_updateDelivery` mit `state = finished` +(historisch Schritt 7) ist entfallen: `_web_finishDelivery` setzt State und +Lieferzeitpunkt jetzt atomar in einem einzigen `UPDATE` auf `Belegkopf`. + +## Offene Punkte + +### 1. ~~Doppelter Abschluss-Call~~ — erledigt (2026-04-26) + +Status: behoben. `_web_updateDelivery` wird im Abschluss-Flow nicht mehr +aufgerufen. `_web_finishDelivery` schreibt `_SV_DELIVERY_STATE` und +`_SV_DELIVERY_DELIVERED_AT` atomar in einem einzigen `UPDATE` auf +`Belegkopf` und führt anschließend `_removeArticles` aus. + +### 3. Hartcodierte Sequenz ohne Retry, generisches Error-Reporting + +Die 7 Requests laufen strikt nacheinander mit `await`. Bei einem Fehler an +einer beliebigen Stelle landet der Flow in `TourBloc._handleError` und +emittiert eine generische Meldung "Fehler beim Abschließen der Lieferung", +ohne den genauen Schritt zu nennen. + +Risiken: +- Partial-Failure-Zustände sind möglich: + - Fehler in 1–3: keine Fahrer-Signatur, kein Abschluss. + - Fehler in 4–6: Fahrer-Signatur ist hochgeladen, Kunden-Signatur nicht, + Lieferung weiterhin offen. + - Fehler in 7 (`_web_finishDelivery`): beide Signaturen liegen am ERP, + State und Lieferzeitpunkt aber nicht gesetzt — Lieferung bleibt + `laufend`. Da der Endpoint atomar ist, gibt es keinen Zwischen-Zustand + "State gesetzt, Zeitstempel fehlt" oder umgekehrt. +- Schlechtes Netz / Funkloch beim Fahrer ist Realität → Wahrscheinlichkeit + ist nicht klein. +- Fahrer kann den Schritt blind wiederholen, ohne zu wissen, ob Signaturen + schon liegen → potenziell doppelte Bilddateien im DMS. +- Diagnose im Support ist mühsam, weil die Fehlermeldung nichts zur Stelle + sagt. + +To-do (mittelfristig): +- Pro Repository-Schritt eine eigene, sprechende Fehlermeldung + ("Fahrersignatur konnte nicht gespeichert werden", "Kundensignatur …", + "Lieferung konnte nicht als abgeschlossen markiert werden"). +- Idempotenz prüfen: lassen sich die Schritte 1–6 ohne Doppel-Effekt + wiederholen? Falls ja, Retry-Strategie mit exponential backoff für + Netzfehler. Falls nein, mit Backend abstimmen. +- Server-Sicht "Wurde Schritt X für Lieferung Y schon erledigt?" einbauen, + damit ein Wiederaufnehmen nach App-Crash/Neustart möglich ist. +- Optional: Outbox-Pattern — Signaturen + Finish-Marker werden lokal + persistiert und im Hintergrund hochgeladen, statt blockierend im UI. diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 565af1d..dfb75db 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -15,7 +15,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 997C0E4FB7B2C67AB8388B3F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FB0CFA44E0F4A317CC3E8B41 /* Pods_RunnerTests.framework */; }; - CAFC9CC8D4DE6A37AF21BCD7 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0F2499FC75E94DB5A00A1507 /* Pods_Runner.framework */; }; + A8B379B8CE90BAD414DCE8D3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0F2499FC75E94DB5A00A1507 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -80,7 +80,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - CAFC9CC8D4DE6A37AF21BCD7 /* Pods_Runner.framework in Frameworks */, + A8B379B8CE90BAD414DCE8D3 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6266644..c30b367 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -2,12 +2,15 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 41e289a..d022272 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,19 +2,8 @@ - - NSCameraUsageDescription - Wir benötigen Zugriff auf deine Kamera zum Scannen von Barcodes. - - - NSLocationWhenInUseUsageDescription - Diese App benötigt keinen Standortzugriff. - - NSLocationWhenInUseUsageDescription - Diese App benötigt deinen Standort, um die Lieferdistanz zu berechnen. - - NSLocationAlwaysAndWhenInUseUsageDescription - Diese App benötigt deinen Standort, um die Lieferdistanz zu berechnen. + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -33,10 +22,50 @@ $(FLUTTER_BUILD_NAME) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + myapp + + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCameraUsageDescription + Wir benötigen Zugriff auf deine Kamera zum Scannen von Barcodes. + NSLocationAlwaysAndWhenInUseUsageDescription + Diese App benötigt deinen Standort, um die Lieferdistanz zu berechnen. + NSLocationWhenInUseUsageDescription + Diese App benötigt deinen Standort, um die Lieferdistanz zu berechnen. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -54,21 +83,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - myapp - - - diff --git a/lib/dto/customer.dart b/lib/dto/customer.dart index bec7f0b..3ebebf9 100644 --- a/lib/dto/customer.dart +++ b/lib/dto/customer.dart @@ -5,10 +5,11 @@ part 'customer.g.dart'; @JsonSerializable(fieldRename: FieldRename.snake) class CustomerDTO { - CustomerDTO({required this.name, required this.address}); + CustomerDTO({required this.name, required this.address, this.eMail}); String name; AddressDTO address; + String? eMail; factory CustomerDTO.fromJson(Map json) => _$CustomerDTOFromJson(json); Map toJson() => _$CustomerDTOToJson(this); diff --git a/lib/dto/customer.g.dart b/lib/dto/customer.g.dart index 609291b..bd772e1 100644 --- a/lib/dto/customer.g.dart +++ b/lib/dto/customer.g.dart @@ -9,7 +9,12 @@ part of 'customer.dart'; CustomerDTO _$CustomerDTOFromJson(Map json) => CustomerDTO( name: json['name'] as String, address: AddressDTO.fromJson(json['address'] as Map), + eMail: json['e_mail'] as String?, ); Map _$CustomerDTOToJson(CustomerDTO instance) => - {'name': instance.name, 'address': instance.address}; + { + 'name': instance.name, + 'address': instance.address, + 'e_mail': instance.eMail, + }; diff --git a/lib/dto/delivery_update.dart b/lib/dto/delivery_update.dart index f6448c6..7aa86c3 100644 --- a/lib/dto/delivery_update.dart +++ b/lib/dto/delivery_update.dart @@ -1,4 +1,5 @@ import 'package:hl_lieferservice/model/delivery.dart'; +import 'package:intl/intl.dart'; import 'package:json_annotation/json_annotation.dart'; part 'delivery_update.g.dart'; @@ -74,7 +75,9 @@ class DeliveryUpdateDTO { carId: delivery.carId?.toString() , selectedPaymentMethodId: delivery.payment.id, options: delivery.options.map(DeliveryOptionUpdateDTO.fromEntity).toList(), - finishedDate: DateTime.now().millisecondsSinceEpoch.toString() + finishedDate: delivery.state == DeliveryState.finished + ? DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now()) + : null, ); } diff --git a/lib/feature/authentication/bloc/auth_bloc.dart b/lib/feature/authentication/bloc/auth_bloc.dart index d2157ac..78d02e0 100644 --- a/lib/feature/authentication/bloc/auth_bloc.dart +++ b/lib/feature/authentication/bloc/auth_bloc.dart @@ -15,15 +15,13 @@ class AuthBloc extends Bloc { : super(Unauthenticated()) { on(_auth); on(_logout); + on(_sessionExpired); } Future _auth( SetAuthenticatedEvent event, Emitter emit, ) async { - operationBloc.add(LoadOperation()); - await Future.delayed(Duration(seconds: 5)); - try { debugPrint("Retrieve user information"); @@ -31,7 +29,6 @@ class AuthBloc extends Bloc { var state = Authenticated(sessionId: event.sessionId, user: response); locator.registerSingleton(state); emit(state); - operationBloc.add(FinishOperation()); } catch (err, st) { debugPrint("Failed to retrieve user information"); debugPrint(err.toString()); @@ -46,6 +43,19 @@ class AuthBloc extends Bloc { } Future _logout(Logout event, Emitter emit) async { + if (locator.isRegistered()) { + locator.unregister(); + } emit(Unauthenticated()); } + + Future _sessionExpired( + SessionExpiredEvent event, + Emitter emit, + ) async { + if (locator.isRegistered()) { + locator.unregister(); + } + emit(Unauthenticated(sessionExpired: true)); + } } diff --git a/lib/feature/authentication/bloc/auth_event.dart b/lib/feature/authentication/bloc/auth_event.dart index bbbb6e3..8c20492 100644 --- a/lib/feature/authentication/bloc/auth_event.dart +++ b/lib/feature/authentication/bloc/auth_event.dart @@ -10,4 +10,6 @@ class Logout extends AuthEvent { String username; Logout({required this.username}); -} \ No newline at end of file +} + +class SessionExpiredEvent extends AuthEvent {} \ No newline at end of file diff --git a/lib/feature/authentication/bloc/auth_state.dart b/lib/feature/authentication/bloc/auth_state.dart index 3b2b1c8..3b2200a 100644 --- a/lib/feature/authentication/bloc/auth_state.dart +++ b/lib/feature/authentication/bloc/auth_state.dart @@ -2,7 +2,11 @@ import 'package:hl_lieferservice/feature/authentication/model/user.dart'; abstract class AuthState {} -class Unauthenticated extends AuthState {} +class Unauthenticated extends AuthState { + final bool sessionExpired; + Unauthenticated({this.sessionExpired = false}); +} + class Authenticated extends AuthState { User user; String sessionId; diff --git a/lib/feature/authentication/presentation/login_enforcer.dart b/lib/feature/authentication/presentation/login_enforcer.dart index 45f6ec4..f9c9c78 100644 --- a/lib/feature/authentication/presentation/login_enforcer.dart +++ b/lib/feature/authentication/presentation/login_enforcer.dart @@ -18,7 +18,8 @@ class LoginEnforcer extends StatelessWidget { return child; } - return LoginPage(); + final expired = state is Unauthenticated && state.sessionExpired; + return LoginPage(sessionExpired: expired); }, ); } diff --git a/lib/feature/authentication/presentation/login_page.dart b/lib/feature/authentication/presentation/login_page.dart index 684b5fb..58b70da 100644 --- a/lib/feature/authentication/presentation/login_page.dart +++ b/lib/feature/authentication/presentation/login_page.dart @@ -7,7 +7,9 @@ import 'package:url_launcher/url_launcher.dart'; import 'dart:async'; class LoginPage extends StatefulWidget { - const LoginPage({super.key}); + final bool sessionExpired; + + const LoginPage({super.key, this.sessionExpired = false}); @override State createState() => _LoginPageState(); @@ -60,7 +62,7 @@ class _LoginPageState extends State { debugPrint("🔵 Opening browser to: http://localhost:3000/login"); - final loginUrl = Uri.parse('http://100.72.100.33:3000/login'); + final loginUrl = Uri.parse('http://192.168.1.9:3000/login'); final launched = await launchUrl( loginUrl, mode: LaunchMode.externalApplication, @@ -127,8 +129,22 @@ class _LoginPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), - body: Center( - child: Column( + body: Column( + children: [ + if (widget.sessionExpired) + MaterialBanner( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + content: const Text( + "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.", + style: TextStyle(color: Colors.white), + ), + backgroundColor: Colors.orange.shade800, + leading: const Icon(Icons.warning_amber_rounded, color: Colors.white), + actions: [const SizedBox.shrink()], + ), + Expanded( + child: Center( + child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ Padding( @@ -179,6 +195,9 @@ class _LoginPageState extends State { ), ], ), + ), + ), + ], ), ); } diff --git a/lib/feature/car_selection/bloc/bloc.dart b/lib/feature/car_selection/bloc/bloc.dart index e69de29..b8a79d0 100644 --- a/lib/feature/car_selection/bloc/bloc.dart +++ b/lib/feature/car_selection/bloc/bloc.dart @@ -0,0 +1,88 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart'; +import 'package:hl_lieferservice/feature/cars/model/selection.dart'; +import 'package:hl_lieferservice/model/car.dart'; + +import 'events.dart'; +import 'state.dart'; + +class CarSelectBloc extends Bloc { + final CarSelectionRepository repository; + + CarSelectBloc({required this.repository}) : super(CarSelectInitial()) { + on(_load); + on(_confirm); + on(_change); + on(_cancel); + } + + Future _load( + CarSelectLoad event, + Emitter emit, + ) async { + try { + emit(CarSelectLoading()); + + final CarSelection? stored = await repository.getSelection(); + final today = DateTime.now(); + + final bool validForToday = + stored != null && + stored.selectedCarId != null && + stored.selectedCarPlate != null && + stored.date.year == today.year && + stored.date.month == today.month && + stored.date.day == today.day; + + if (validForToday) { + emit( + CarSelectComplete( + selectedCar: Car( + id: stored.selectedCarId!, + plate: stored.selectedCarPlate!, + ), + ), + ); + } else { + emit(CarSelectRequired()); + } + } catch (e, st) { + debugPrint('CarSelectBloc._load failed: $e'); + debugPrint('Stacktrace: $st'); + emit(CarSelectFailed()); + } + } + + void _change(CarSelectChange event, Emitter emit) { + final previousCar = + state is CarSelectComplete ? (state as CarSelectComplete).selectedCar : null; + emit(CarSelectRequired(previousCar: previousCar)); + } + + void _cancel(CarSelectCancel event, Emitter emit) { + // Restore without touching SharedPreferences — no tour reload needed. + emit(CarSelectComplete(selectedCar: event.car)); + } + + Future _confirm( + CarSelectConfirm event, + Emitter emit, + ) async { + try { + final today = DateTime.now(); + await repository.saveSelection( + CarSelection( + date: today, + selectedCarId: event.car.id, + selectedCarPlate: event.car.plate, + ), + ); + emit(CarSelectComplete(selectedCar: event.car)); + } catch (e, st) { + debugPrint('CarSelectBloc._confirm failed: $e'); + debugPrint('Stacktrace: $st'); + emit(CarSelectFailed()); + } + } +} \ No newline at end of file diff --git a/lib/feature/car_selection/bloc/events.dart b/lib/feature/car_selection/bloc/events.dart index e69de29..b77591a 100644 --- a/lib/feature/car_selection/bloc/events.dart +++ b/lib/feature/car_selection/bloc/events.dart @@ -0,0 +1,25 @@ +import 'package:hl_lieferservice/model/car.dart'; + +abstract class CarSelectEvent {} + +/// Fired at app startup to check if a car has already been selected for today. +class CarSelectLoad extends CarSelectEvent {} + +/// Fired when the driver confirms their car choice for the day. +class CarSelectConfirm extends CarSelectEvent { + final Car car; + + CarSelectConfirm({required this.car}); +} + +/// Fired when the driver wants to switch to a different car. +/// Resets the selection so the enforcer shows the picker again. +class CarSelectChange extends CarSelectEvent {} + +/// Fired when the driver cancels the change and wants to keep the previous car. +/// Restores [CarSelectComplete] without writing to SharedPreferences. +class CarSelectCancel extends CarSelectEvent { + final Car car; + + CarSelectCancel({required this.car}); +} \ No newline at end of file diff --git a/lib/feature/car_selection/bloc/state.dart b/lib/feature/car_selection/bloc/state.dart index e69de29..dc478da 100644 --- a/lib/feature/car_selection/bloc/state.dart +++ b/lib/feature/car_selection/bloc/state.dart @@ -0,0 +1,25 @@ +import 'package:hl_lieferservice/model/car.dart'; + +abstract class CarSelectState {} + +class CarSelectInitial extends CarSelectState {} + +class CarSelectLoading extends CarSelectState {} + +/// No valid car selection exists for today — the driver must choose. +/// [previousCar] is set when the driver triggered a manual change, +/// allowing the page to pre-highlight the current car and offer a cancel. +class CarSelectRequired extends CarSelectState { + final Car? previousCar; + + CarSelectRequired({this.previousCar}); +} + +/// A car has been selected for today. The selection is persisted locally. +class CarSelectComplete extends CarSelectState { + final Car selectedCar; + + CarSelectComplete({required this.selectedCar}); +} + +class CarSelectFailed extends CarSelectState {} \ No newline at end of file diff --git a/lib/feature/car_selection/presentation/car_selection_card.dart b/lib/feature/car_selection/presentation/car_selection_card.dart new file mode 100644 index 0000000..7c87924 --- /dev/null +++ b/lib/feature/car_selection/presentation/car_selection_card.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:hl_lieferservice/model/car.dart'; + +class CarSelectionCard extends StatelessWidget { + final Car car; + final bool isSelected; + final VoidCallback onTap; + + const CarSelectionCard({ + super.key, + required this.car, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).primaryColor; + + return Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: isSelected + ? BorderSide(color: color, width: 2) + : BorderSide.none, + ), + color: isSelected + ? color.withValues(alpha: 0.08) + : null, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon( + Icons.local_shipping, + size: 32, + color: isSelected ? color : Colors.grey, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + car.plate, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + if (isSelected) + Icon(Icons.check_circle, color: color), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/feature/car_selection/presentation/car_selection_enforcer.dart b/lib/feature/car_selection/presentation/car_selection_enforcer.dart new file mode 100644 index 0000000..54a85da --- /dev/null +++ b/lib/feature/car_selection/presentation/car_selection_enforcer.dart @@ -0,0 +1,82 @@ +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/car_selection/presentation/car_selection_page.dart'; + +class CarSelectionEnforcer extends StatefulWidget { + final Widget child; + + const CarSelectionEnforcer({super.key, required this.child}); + + @override + State createState() => _CarSelectionEnforcerState(); +} + +class _CarSelectionEnforcerState extends State { + @override + void initState() { + super.initState(); + context.read().add(CarSelectLoad()); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + // Show a full-screen spinner only while the persisted selection is + // being read from SharedPreferences (at most one frame on cold start). + if (state is CarSelectInitial || state is CarSelectLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + if (state is CarSelectFailed) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 72, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 24), + const Text( + "Fehler beim Laden der Fahrzeugauswahl.", + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => + context.read().add(CarSelectLoad()), + child: const Text("Erneut versuchen"), + ), + ], + ), + ), + ), + ); + } + + // For both CarSelectRequired and CarSelectComplete, keep Home alive + // in the widget tree so initState is never re-triggered. The selection + // page is overlaid on top when a (re-)selection is required. + return Stack( + children: [ + widget.child, + if (state is CarSelectRequired) + Positioned.fill( + child: CarSelectionPage(previousCar: state.previousCar), + ), + ], + ); + }, + ); + } +} diff --git a/lib/feature/car_selection/presentation/car_selection_page.dart b/lib/feature/car_selection/presentation/car_selection_page.dart new file mode 100644 index 0000000..33d47d7 --- /dev/null +++ b/lib/feature/car_selection/presentation/car_selection_page.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.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/car_selection/presentation/car_selection_card.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_event.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; +import 'package:hl_lieferservice/feature/cars/presentation/car_dialog.dart'; +import 'package:hl_lieferservice/model/car.dart'; + +class CarSelectionPage extends StatefulWidget { + /// When set, the page is in "change" mode: the car is pre-highlighted + /// and a cancel button is shown to revert without choosing a new car. + final Car? previousCar; + + const CarSelectionPage({super.key, this.previousCar}); + + @override + State createState() => _CarSelectionPageState(); +} + +class _CarSelectionPageState extends State { + Car? _selectedCar; + + bool get _isChanging => widget.previousCar != null; + + @override + void initState() { + super.initState(); + _selectedCar = widget.previousCar; + final authState = context.read().state as Authenticated; + context.read().add(CarLoad(teamId: authState.user.number)); + } + + void _onAddCar() { + final authState = context.read().state as Authenticated; + showDialog( + context: context, + builder: (_) => CarDialog( + onAction: (plate) { + context.read().add( + CarAdd(teamId: authState.user.number, plate: plate), + ); + }, + ), + ); + } + + void _onConfirm() { + if (_selectedCar == null) return; + context.read().add(CarSelectConfirm(car: _selectedCar!)); + } + + Widget _buildCarList(List cars) { + if (cars.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.local_shipping_outlined, size: 72, color: Colors.grey), + const SizedBox(height: 24), + Text( + "Noch kein Fahrzeug vorhanden.", + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + "Füge zuerst ein Fahrzeug hinzu, bevor du fortfahren kannst.", + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _onAddCar, + icon: const Icon(Icons.add), + label: const Text("Fahrzeug hinzufügen"), + ), + ], + ), + ), + ); + } + + final authState = context.read().state as Authenticated; + return RefreshIndicator( + onRefresh: () async { + context.read().add( + CarLoad(teamId: authState.user.number, force: true), + ); + }, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: cars.length, + itemBuilder: (context, index) { + final car = cars[index]; + return CarSelectionCard( + car: car, + isSelected: _selectedCar?.id == car.id, + onTap: () => setState(() => _selectedCar = car), + ); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state is CarSelectFailed) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Fehler beim Speichern der Fahrzeugauswahl."), + ), + ); + } + }, + child: Scaffold( + appBar: _isChanging + ? AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => context.read().add( + CarSelectCancel(car: widget.previousCar!), + ), + ), + title: const Text("Fahrzeug wechseln"), + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + ) + : null, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!_isChanging) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 4), + child: Text( + "Fahrzeug auswählen", + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 16), + child: Text( + "Wähle das Fahrzeug aus, das du heute verwendest.", + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey, + ), + ), + ), + ], + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state is CarsLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is CarsLoaded) { + return _buildCarList(state.cars); + } + + if (state is CarsLoadingFailed) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 72, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 24), + const Text( + "Fehler beim Laden der Fahrzeuge.", + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () { + final authState = + context.read().state + as Authenticated; + context.read().add( + CarLoad( + teamId: authState.user.number, + ), + ); + }, + child: const Text("Erneut versuchen"), + ), + ], + ), + ), + ); + } + + return const SizedBox.shrink(); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _selectedCar != null ? _onConfirm : null, + child: const Text("Auswählen"), + ), + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/feature/car_selection/presentation/selected_car_bar.dart b/lib/feature/car_selection/presentation/selected_car_bar.dart new file mode 100644 index 0000000..04f3846 --- /dev/null +++ b/lib/feature/car_selection/presentation/selected_car_bar.dart @@ -0,0 +1,56 @@ +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'; + +class SelectedCarBar extends StatelessWidget { + const SelectedCarBar({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is! CarSelectComplete) return const SizedBox.shrink(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(height: 1, thickness: 1), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon( + Icons.local_shipping, + size: 20, + color: Theme.of(context).primaryColor, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + state.selectedCar.plate, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ), + ), + OutlinedButton.icon( + onPressed: () => + context.read().add(CarSelectChange()), + icon: const Icon(Icons.swap_horiz, size: 18), + label: const Text("Wechseln"), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/feature/car_selection/repository/car_selection_repository.dart b/lib/feature/car_selection/repository/car_selection_repository.dart new file mode 100644 index 0000000..f064ed7 --- /dev/null +++ b/lib/feature/car_selection/repository/car_selection_repository.dart @@ -0,0 +1,34 @@ +import 'package:hl_lieferservice/feature/cars/model/selection.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class CarSelectionRepository { + static const _keyDate = 'car_selection_date'; + static const _keyCarId = 'car_selection_car_id'; + static const _keyCarPlate = 'car_selection_car_plate'; + + /// Returns the stored [CarSelection], or null if nothing has been saved yet. + Future getSelection() async { + final prefs = await SharedPreferences.getInstance(); + + final dateString = prefs.getString(_keyDate); + final carId = prefs.getInt(_keyCarId); + final plate = prefs.getString(_keyCarPlate); + + if (dateString == null || carId == null || plate == null) return null; + + return CarSelection( + date: DateTime.parse(dateString), + selectedCarId: carId, + selectedCarPlate: plate, + ); + } + + /// Persists the given [selection] locally on this device. + Future saveSelection(CarSelection selection) async { + final prefs = await SharedPreferences.getInstance(); + + await prefs.setString(_keyDate, selection.date.toIso8601String()); + await prefs.setInt(_keyCarId, selection.selectedCarId!); + await prefs.setString(_keyCarPlate, selection.selectedCarPlate!); + } +} \ No newline at end of file diff --git a/lib/feature/cars/bloc/cars_bloc.dart b/lib/feature/cars/bloc/cars_bloc.dart index 66d4052..dac99e9 100644 --- a/lib/feature/cars/bloc/cars_bloc.dart +++ b/lib/feature/cars/bloc/cars_bloc.dart @@ -1,4 +1,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart'; +import 'package:hl_lieferservice/feature/authentication/exceptions.dart'; import 'package:hl_lieferservice/feature/cars/repository/cars_repository.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; @@ -10,8 +13,9 @@ import 'cars_state.dart'; class CarsBloc extends Bloc { CarsRepository repository; OperationBloc opBloc; + AuthBloc authBloc; - CarsBloc({required this.repository, required this.opBloc}) + CarsBloc({required this.repository, required this.opBloc, required this.authBloc}) : super(CarsInitial()) { on(_carAdd); on(_carEdit); @@ -19,12 +23,27 @@ class CarsBloc extends Bloc { on(_carLoad); } + void _handleError(Object e, String fallbackMessage) { + if (e is UserUnauthorized) { + authBloc.add(SessionExpiredEvent()); + } else { + opBloc.add(FailOperation(message: fallbackMessage)); + } + } + Future _carLoad(CarLoad event, Emitter emit) async { + // Skip the API call if cars are already loaded and no force-refresh requested. + if (state is CarsLoaded && !event.force) return; + try { emit(CarsLoading()); List cars = await repository.getAll(event.teamId); emit(CarsLoaded(cars: cars, teamId: event.teamId)); } catch (e) { + if (e is UserUnauthorized) { + authBloc.add(SessionExpiredEvent()); + return; + } emit(CarsLoadingFailed()); } } @@ -33,7 +52,6 @@ class CarsBloc extends Bloc { final currentState = state; try { - opBloc.add(LoadOperation()); Car newCar = await repository.add(event.teamId, event.plate); if (currentState is CarsLoaded) { @@ -46,7 +64,7 @@ class CarsBloc extends Bloc { opBloc.add(FinishOperation(message: "Auto erfolgreich hinzugefügt")); } catch (e) { - opBloc.add(FailOperation(message: "Fehler beim Hinzufügen eines Autos")); + _handleError(e, "Fehler beim Hinzufügen eines Autos"); } } @@ -54,7 +72,6 @@ class CarsBloc extends Bloc { final currentState = state; try { - opBloc.add(LoadOperation()); await repository.edit(event.teamId, event.newCar); if (currentState is CarsLoaded) { @@ -74,7 +91,7 @@ class CarsBloc extends Bloc { opBloc.add(FinishOperation(message: "Auto erfolgreich editiert")); } catch (e) { - opBloc.add(FailOperation(message: "Fehler beim Editieren des Autos")); + _handleError(e, "Fehler beim Editieren des Autos"); } } @@ -82,7 +99,6 @@ class CarsBloc extends Bloc { final currentState = state; try { - opBloc.add(LoadOperation()); await repository.delete(event.carId, event.teamId); if (currentState is CarsLoaded) { @@ -100,7 +116,7 @@ class CarsBloc extends Bloc { opBloc.add(FinishOperation(message: "Auto erfolgreich gelöscht")); } catch (e) { - opBloc.add(FailOperation(message: "Fehler beim Löschen des Autos")); + _handleError(e, "Fehler beim Löschen des Autos"); } } } diff --git a/lib/feature/cars/bloc/cars_event.dart b/lib/feature/cars/bloc/cars_event.dart index b71c280..a522e5c 100644 --- a/lib/feature/cars/bloc/cars_event.dart +++ b/lib/feature/cars/bloc/cars_event.dart @@ -5,7 +5,11 @@ abstract class CarEvents {} class CarLoad extends CarEvents { String teamId; - CarLoad({required this.teamId}); + /// If [force] is true the API is always called, bypassing the cache. + /// Use this for pull-to-refresh. Defaults to false. + bool force; + + CarLoad({required this.teamId, this.force = false}); } class CarEdit extends CarEvents { diff --git a/lib/feature/cars/model/selection.dart b/lib/feature/cars/model/selection.dart index e69de29..d2d31e0 100644 --- a/lib/feature/cars/model/selection.dart +++ b/lib/feature/cars/model/selection.dart @@ -0,0 +1,14 @@ +/* + Settings for the driver to select a car for the current workday. +*/ +class CarSelection { + final DateTime date; + final int? selectedCarId; + final String? selectedCarPlate; + + CarSelection({ + required this.date, + this.selectedCarId, + this.selectedCarPlate, + }); +} diff --git a/lib/feature/cars/presentation/car_card.dart b/lib/feature/cars/presentation/car_card.dart index 4a06c6b..73442e2 100644 --- a/lib/feature/cars/presentation/car_card.dart +++ b/lib/feature/cars/presentation/car_card.dart @@ -5,6 +5,7 @@ import 'car_dialog.dart'; class CarCard extends StatelessWidget { final Car car; + final bool isSelected; final Function(Car car) onDelete; final Function(Car car, String newName) onEdit; @@ -13,11 +14,20 @@ class CarCard extends StatelessWidget { required this.car, required this.onEdit, required this.onDelete, + this.isSelected = false, }); @override Widget build(BuildContext context) { + final primary = Theme.of(context).primaryColor; return Card( + color: isSelected ? primary.withValues(alpha: 0.08) : null, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: isSelected + ? BorderSide(color: primary, width: 2) + : BorderSide.none, + ), child: Padding( padding: const EdgeInsets.all(10), child: Row( @@ -30,13 +40,30 @@ class CarCard extends StatelessWidget { child: Icon( Icons.local_shipping, size: 32, - color: Theme.of(context).primaryColor, + color: primary, ), ), Padding( padding: const EdgeInsets.only(left: 10), - child: Text(car.plate), + child: Text( + car.plate, + style: TextStyle( + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), ), + if (isSelected) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon( + Icons.check_circle, + size: 20, + color: primary, + semanticLabel: 'Aktuell ausgewählt', + ), + ), ], ), diff --git a/lib/feature/cars/presentation/car_management.dart b/lib/feature/cars/presentation/car_management.dart index 3ccbd46..ae3dbc5 100644 --- a/lib/feature/cars/presentation/car_management.dart +++ b/lib/feature/cars/presentation/car_management.dart @@ -6,9 +6,11 @@ import 'package:flutter/material.dart'; class CarManagementOverview extends StatefulWidget { final List cars; + final int? selectedCarId; final Function(String plate) onAdd; final Function(String id) onDelete; final Function(String id, String plate) onEdit; + final Future Function() onRefresh; const CarManagementOverview({ super.key, @@ -16,6 +18,8 @@ class CarManagementOverview extends StatefulWidget { required this.onDelete, required this.onEdit, required this.onAdd, + required this.onRefresh, + this.selectedCarId, }); @override @@ -40,30 +44,14 @@ class _CarManagementOverviewState extends State { widget.onEdit(car.id.toString(), newName); } - Widget _buildCarOverview() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding(padding: const EdgeInsets.all(15), child: Text("Fahrzeuge", style: Theme.of(context).textTheme.headlineSmall),), - Expanded(child: Padding( - padding: const EdgeInsets.all(10), - child: widget.cars.isEmpty ? const Center(child: Text("keine Fahrzeuge vorhanden")) : ListView.builder( - itemBuilder: - (context, index) => CarCard( - car: widget.cars[index], - onEdit: _editCar, - onDelete: _removeCar, - ), - itemCount: widget.cars.length, - ), - )) - ], - ); - } - @override Widget build(BuildContext context) { return Scaffold( + appBar: AppBar( + title: const Text("Fahrzeuge"), + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + ), floatingActionButton: FloatingActionButton( onPressed: _addCar, backgroundColor: Theme.of(context).primaryColor, @@ -72,7 +60,34 @@ class _CarManagementOverviewState extends State { color: Theme.of(context).colorScheme.onSecondary, ), ), - body: _buildCarOverview(), + body: RefreshIndicator( + onRefresh: widget.onRefresh, + child: widget.cars.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(10), + children: const [ + SizedBox( + height: 200, + child: Center(child: Text("keine Fahrzeuge vorhanden")), + ), + ], + ) + : ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(10), + itemCount: widget.cars.length, + itemBuilder: (context, index) { + final car = widget.cars[index]; + return CarCard( + car: car, + isSelected: widget.selectedCarId == car.id, + onEdit: _editCar, + onDelete: _removeCar, + ); + }, + ), + ), ); } } diff --git a/lib/feature/cars/presentation/car_management_page.dart b/lib/feature/cars/presentation/car_management_page.dart index 750675b..9bde7ac 100644 --- a/lib/feature/cars/presentation/car_management_page.dart +++ b/lib/feature/cars/presentation/car_management_page.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.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_event.dart'; import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; @@ -37,7 +39,55 @@ class _CarManagementPageState extends State { ); } + Future _refresh() async { + context.read().add(CarLoad(teamId: _authState.user.number, force: true)); + } + void _remove(String id) { + final carId = int.parse(id); + + final carSelectState = context.read().state; + if (carSelectState is CarSelectComplete && + carSelectState.selectedCar.id == carId) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Dieses Fahrzeug ist aktuell ausgewählt und kann nicht gelöscht werden. " + "Bitte wähle zuerst ein anderes Fahrzeug aus.", + ), + duration: Duration(seconds: 4), + ), + ); + return; + } + + final tourState = context.read().state; + if (tourState is! TourLoaded) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Die Tourdaten sind noch nicht verfügbar. " + "Bitte versuche es in Kürze erneut.", + ), + duration: Duration(seconds: 4), + ), + ); + return; + } + + if (tourState.tour.hasUndeliveredLoadedArticles(carId)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Dieses Fahrzeug hat noch geladene Artikel, die nicht ausgeliefert wurden. " + "Bitte schließe alle offenen Lieferungen ab, bevor du das Fahrzeug löschst.", + ), + duration: Duration(seconds: 4), + ), + ); + return; + } + context.read().add( CarDelete(carId: id, teamId: _authState.user.number), ); @@ -68,11 +118,20 @@ class _CarManagementPageState extends State { } if (state is CarsLoaded) { - return CarManagementOverview( - cars: state.cars, - onEdit: _edit, - onAdd: _add, - onDelete: _remove, + return BlocBuilder( + builder: (context, selectState) { + final int? selectedCarId = selectState is CarSelectComplete + ? selectState.selectedCar.id + : null; + return CarManagementOverview( + cars: state.cars, + selectedCarId: selectedCarId, + onEdit: _edit, + onAdd: _add, + onDelete: _remove, + onRefresh: _refresh, + ); + }, ); } diff --git a/lib/feature/delivery/bloc/tour_bloc.dart b/lib/feature/delivery/bloc/tour_bloc.dart index 75d1aa3..d0a95ce 100644 --- a/lib/feature/delivery/bloc/tour_bloc.dart +++ b/lib/feature/delivery/bloc/tour_bloc.dart @@ -9,16 +9,20 @@ import 'package:hl_lieferservice/feature/delivery/overview/service/distance_serv import 'package:hl_lieferservice/feature/delivery/overview/service/reorder_service.dart'; import 'package:hl_lieferservice/feature/delivery/util.dart'; import 'package:hl_lieferservice/model/tour.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart'; +import 'package:hl_lieferservice/feature/authentication/exceptions.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; import 'package:rxdart/rxdart.dart'; class TourBloc extends Bloc { OperationBloc opBloc; + AuthBloc authBloc; TourRepository tourRepository; StreamSubscription? _combinedSubscription; - TourBloc({required this.opBloc, required this.tourRepository}) + TourBloc({required this.opBloc, required this.authBloc, required this.tourRepository}) : super(TourInitial()) { _combinedSubscription = CombineLatestStream.combine2( tourRepository.tour, @@ -61,17 +65,23 @@ class TourBloc extends Bloc { @override Future close() { _combinedSubscription?.cancel(); - return super.close(); } + void _handleError(Object e, String fallbackMessage) { + if (e is UserUnauthorized) { + authBloc.add(SessionExpiredEvent()); + } else { + opBloc.add(FailOperation(message: fallbackMessage)); + } + } + void _setArticleAmount( SetArticleAmountEvent event, Emitter emit, ) async { final currentState = state; if (currentState is TourLoaded) { - opBloc.add(LoadOperation()); try { await tourRepository.setArticleAmount( event.deliveryId, @@ -79,15 +89,9 @@ class TourBloc extends Bloc { event.amount, event.reason, ); - - opBloc.add(FinishOperation()); } catch (e, st) { - opBloc.add( - FailOperation(message: "Fehler beim Ändern der Menge des Artikels"), - ); - - debugPrint("$e"); - debugPrint("$st"); + debugPrint("$e $st"); + _handleError(e, "Fehler beim Ändern der Menge des Artikels"); } } } @@ -126,7 +130,6 @@ class TourBloc extends Bloc { Emitter emit, ) async { Map distances = {}; - opBloc.add(LoadOperation()); emit(TourRequestingDistances(tour: event.tour, payments: event.payments)); @@ -135,7 +138,7 @@ class TourBloc extends Bloc { distances[delivery.id] = await DistanceService.getDistanceByRoad( delivery.customer.address.toString(), ); - } catch (e,st) { + } catch (e, st) { debugPrint("Fehler beim Laden der Distanz: $e"); debugPrint("$st"); @@ -145,7 +148,6 @@ class TourBloc extends Bloc { } } - opBloc.add(FinishOperation()); // If an error occurred, then the distances will be empty // If the distances are empty then they shouldn't be displayed add( @@ -251,17 +253,11 @@ class TourBloc extends Bloc { ) async { final currentState = state; if (currentState is TourLoaded) { - opBloc.add(LoadOperation()); try { await tourRepository.reactivateDelivery(event.deliveryId); - - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint("$e"); - debugPrint("$st"); - opBloc.add( - FailOperation(message: "Fehler beim Zurückstellen der Lieferung"), - ); + debugPrint("$e $st"); + _handleError(e, "Fehler beim Zurückstellen der Lieferung"); } } } @@ -269,17 +265,11 @@ class TourBloc extends Bloc { void _holdDelivery(HoldDeliveryEvent event, Emitter emit) async { final currentState = state; if (currentState is TourLoaded) { - opBloc.add(LoadOperation()); try { await tourRepository.holdDelivery(event.deliveryId); - - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint("$e"); - debugPrint("$st"); - opBloc.add( - FailOperation(message: "Fehler beim Zurückstellen der Lieferung"), - ); + debugPrint("$e $st"); + _handleError(e, "Fehler beim Zurückstellen der Lieferung"); } } } @@ -290,24 +280,17 @@ class TourBloc extends Bloc { ) async { final currentState = state; if (currentState is TourLoaded) { - opBloc.add(LoadOperation()); try { await tourRepository.cancelDelivery(event.deliveryId); - - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint("$e"); - debugPrint("$st"); - opBloc.add( - FailOperation(message: "Fehler beim Zurückstellen der Lieferung"), - ); + debugPrint("$e $st"); + _handleError(e, "Fehler beim Stornieren der Lieferung"); } } } void _scan(ScanArticleEvent event, Emitter emit) async { final currentState = state; - opBloc.add(LoadOperation()); if (currentState is TourLoaded) { try { @@ -333,9 +316,8 @@ class TourBloc extends Bloc { break; } } catch (e, st) { - debugPrint("FEHLER beim Scannen eines Artikels: $e"); - debugPrint("$st"); - opBloc.add(FailOperation(message: "Fehler beim Scannen des Artikels")); + debugPrint("FEHLER beim Scannen eines Artikels: $e $st"); + _handleError(e, "Fehler beim Scannen des Artikels"); } } } @@ -347,17 +329,15 @@ class TourBloc extends Bloc { final currentState = state; if (currentState is TourLoaded) { - opBloc.add(LoadOperation()); try { await tourRepository.scanArticle( event.deliveryId, event.carId, event.internalArticleId, ); - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint(st.toString()); - opBloc.add(FailOperation(message: "Fehler beim Scannen des Artikels")); + debugPrint("$e $st"); + _handleError(e, "Fehler beim Scannen des Artikels"); } } } @@ -365,34 +345,27 @@ class TourBloc extends Bloc { Future _assignCar(AssignCarEvent event, Emitter emit) async { final currentState = state; if (currentState is TourLoaded) { - opBloc.add(LoadOperation()); try { await tourRepository.assignCar(event.deliveryId, event.carId); - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint(st.toString()); - opBloc.add( - FailOperation(message: "Fehler beim Zuweisen des Fahrzeugs"), - ); + debugPrint("$e $st"); + _handleError(e, "Fehler beim Zuweisen des Fahrzeugs"); } } } Future _load(LoadTour event, Emitter emit) async { - opBloc.add(LoadOperation()); try { emit(TourLoading()); await tourRepository.loadTourOfToday(event.teamId); await tourRepository.loadPaymentOptions(); - - opBloc.add(FinishOperation()); } catch (e) { - // go to the error state in order to give the user the chance - // to reload if necessary. + if (e is UserUnauthorized) { + authBloc.add(SessionExpiredEvent()); + return; + } emit(TourLoadingFailed()); - opBloc.add( - FailOperation(message: "Fehler beim Laden der heutigen Fahrten"), - ); + opBloc.add(FailOperation(message: "Fehler beim Laden der heutigen Fahrten")); } } @@ -401,7 +374,6 @@ class TourBloc extends Bloc { Emitter emit, ) async { final currentState = state; - opBloc.add(LoadOperation()); if (currentState is TourLoaded) { try { @@ -415,11 +387,9 @@ class TourBloc extends Bloc { ); await tourRepository.finishDelivery(event.deliveryId); - - opBloc.add(FinishOperation()); } catch (e, st) { - opBloc.add(FailOperation(message: "Failed to update delivery")); - debugPrint(st.toString()); + debugPrint("$e $st"); + _handleError(e, "Fehler beim Abschließen der Lieferung"); } } } @@ -429,14 +399,10 @@ class TourBloc extends Bloc { Emitter emit, ) async { try { - opBloc.add(LoadOperation()); await tourRepository.updatePayment(event.deliveryId, event.payment); - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint(st.toString()); - opBloc.add( - FailOperation(message: "Fehler beim Aktualisieren des Betrags"), - ); + debugPrint("$e $st"); + _handleError(e, "Fehler beim Aktualisieren des Betrags"); } } @@ -445,18 +411,14 @@ class TourBloc extends Bloc { Emitter emit, ) async { try { - opBloc.add(LoadOperation()); await tourRepository.updateOption( event.deliveryId, event.key, event.value, ); - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint("$st"); - opBloc.add( - FailOperation(message: "Fehler beim Aktualisieren der Optionen"), - ); + debugPrint("$e $st"); + _handleError(e, "Fehler beim Aktualisieren der Optionen"); } } @@ -464,26 +426,15 @@ class TourBloc extends Bloc { UpdateDiscountEvent event, Emitter emit, ) async { - opBloc.add(LoadOperation()); - try { - opBloc.add(FinishOperation()); await tourRepository.updateDiscount( event.deliveryId, event.reason, event.value, ); - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint( - "Fehler beim Hinzufügen eins Discounts zur Lieferung: ${event.deliveryId}:", - ); - debugPrint("$e"); - debugPrint("$st"); - - opBloc.add( - FailOperation(message: "Fehler beim Hinzufügen des Discounts: $e"), - ); + debugPrint("Fehler beim Aktualisieren des Discounts: $e $st"); + _handleError(e, "Fehler beim Aktualisieren des Discounts"); } } @@ -491,51 +442,28 @@ class TourBloc extends Bloc { RemoveDiscountEvent event, Emitter emit, ) async { - opBloc.add(LoadOperation()); - try { await tourRepository.removeDiscount(event.deliveryId); - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint( - "Fehler beim Löschen des Discounts der Lieferung: ${event.deliveryId}:", - ); - debugPrint("$e"); - debugPrint("$st"); - - opBloc.add( - FailOperation(message: "Fehler beim Löschen des Discounts: $e"), - ); + debugPrint("Fehler beim Löschen des Discounts: $e $st"); + _handleError(e, "Fehler beim Löschen des Discounts"); } } void _addDiscount(AddDiscountEvent event, Emitter emit) async { - opBloc.add(LoadOperation()); - try { await tourRepository.addDiscount( event.deliveryId, event.reason, event.value, ); - - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint( - "Fehler beim Hinzufügen eins Discounts zur Lieferung: ${event.deliveryId}:", - ); - debugPrint("$e"); - debugPrint("$st"); - - opBloc.add( - FailOperation(message: "Fehler beim Hinzufügen des Discounts: $e"), - ); + debugPrint("Fehler beim Hinzufügen des Discounts: $e $st"); + _handleError(e, "Fehler beim Hinzufügen des Discounts"); } } void _unscan(UnscanArticleEvent event, Emitter emit) async { - opBloc.add(LoadOperation()); - try { await tourRepository.unscan( event.deliveryId, @@ -543,29 +471,18 @@ class TourBloc extends Bloc { event.newAmount, event.reason, ); - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint("Fehler beim Unscan des Artikels: ${event.articleId}:"); - debugPrint("$e"); - debugPrint("$st"); - - opBloc.add(FailOperation(message: "Fehler beim Unscan des Artikels: $e")); + debugPrint("Fehler beim Unscan des Artikels ${event.articleId}: $e $st"); + _handleError(e, "Fehler beim Unscan des Artikels"); } } void _resetAmount(ResetScanAmountEvent event, Emitter emit) async { - opBloc.add(LoadOperation()); - try { await tourRepository.resetScan(event.articleId, event.deliveryId); - - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint("Fehler beim Unscan des Artikels: ${event.articleId}:"); - debugPrint("$e"); - debugPrint("$st"); - - opBloc.add(FailOperation(message: "Fehler beim Zurücksetzen: $e")); + debugPrint("Fehler beim Zurücksetzen Artikel ${event.articleId}: $e $st"); + _handleError(e, "Fehler beim Zurücksetzen"); } } } diff --git a/lib/feature/delivery/detail/bloc/note_bloc.dart b/lib/feature/delivery/detail/bloc/note_bloc.dart index 14f6b9d..6fa8bb9 100644 --- a/lib/feature/delivery/detail/bloc/note_bloc.dart +++ b/lib/feature/delivery/detail/bloc/note_bloc.dart @@ -3,6 +3,9 @@ import 'dart:typed_data'; import 'package:flutter/cupertino.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart'; +import 'package:hl_lieferservice/feature/authentication/exceptions.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; import 'package:rxdart/rxdart.dart'; @@ -15,6 +18,7 @@ import 'package:hl_lieferservice/feature/delivery/detail/repository/note_reposit class NoteBloc extends Bloc { final NoteRepository repository; final OperationBloc opBloc; + final AuthBloc authBloc; final String deliveryId; StreamSubscription? _combinedSubscription; @@ -22,6 +26,7 @@ class NoteBloc extends Bloc { NoteBloc({ required this.repository, required this.opBloc, + required this.authBloc, required this.deliveryId, }) : super(NoteInitial()) { _combinedSubscription = CombineLatestStream.combine3( @@ -60,10 +65,17 @@ class NoteBloc extends Bloc { @override Future close() { _combinedSubscription?.cancel(); - return super.close(); } + void _handleError(Object e, String fallbackMessage) { + if (e is UserUnauthorized) { + authBloc.add(SessionExpiredEvent()); + } else { + opBloc.add(FailOperation(message: fallbackMessage)); + } + } + Future _dataUpdated(DataUpdated event, Emitter emit) async { emit( NoteLoaded( @@ -82,32 +94,21 @@ class NoteBloc extends Bloc { RemoveImageNote event, Emitter emit, ) async { - opBloc.add(LoadOperation()); - try { await repository.deleteImage(event.deliveryId, event.objectId); - - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint("Fehler beim Löschen des Bildes: $e"); - debugPrint(st.toString()); - - opBloc.add(FailOperation(message: e.toString())); + debugPrint("Fehler beim Löschen des Bildes: $e $st"); + _handleError(e, "Fehler beim Löschen des Bildes"); } } Future _upload(AddImageNote event, Emitter emit) async { - opBloc.add(LoadOperation()); - try { Uint8List imageBytes = await event.file.readAsBytes(); await repository.addImage(event.deliveryId, imageBytes); - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint("Fehler beim Hinzufügen des Bildes: $e"); - debugPrint(st.toString()); - - opBloc.add(FailOperation(message: e.toString())); + debugPrint("Fehler beim Hinzufügen des Bildes: $e $st"); + _handleError(e, "Fehler beim Hinzufügen des Bildes"); } } @@ -117,61 +118,41 @@ class NoteBloc extends Bloc { try { await repository.loadNotes(event.delivery.id); await repository.loadTemplates(); - - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint("Fehler beim Herunterladen der Notizen: $e"); - debugPrint(st.toString()); - - opBloc.add( - FailOperation(message: "Notizen konnten nicht heruntergeladen werden."), - ); - + debugPrint("Fehler beim Herunterladen der Notizen: $e $st"); + if (e is UserUnauthorized) { + authBloc.add(SessionExpiredEvent()); + return; + } + opBloc.add(FailOperation(message: "Notizen konnten nicht heruntergeladen werden.")); emit.call(NoteLoadingFailed()); } } Future _add(AddNote event, Emitter emit) async { - opBloc.add(LoadOperation()); - try { await repository.addNote(event.deliveryId, event.note); - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint("Fehler beim Hinzufügen der Notiz: $e"); - debugPrint(st.toString()); - - opBloc.add(FailOperation(message: e.toString())); + debugPrint("Fehler beim Hinzufügen der Notiz: $e $st"); + _handleError(e, "Fehler beim Hinzufügen der Notiz"); } } Future _edit(EditNote event, Emitter emit) async { - opBloc.add(LoadOperation()); - try { await repository.editNote(event.noteId, event.content); - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint("Fehler beim Hinzufügen der Notiz: $e"); - debugPrint(st.toString()); - - opBloc.add(FailOperation(message: e.toString())); + debugPrint("Fehler beim Editieren der Notiz: $e $st"); + _handleError(e, "Fehler beim Editieren der Notiz"); } } Future _remove(RemoveNote event, Emitter emit) async { - opBloc.add(LoadOperation()); - try { await repository.deleteNote(event.noteId); - opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint("Fehler beim Hinzufügen der Notiz: $e"); - debugPrint(st.toString()); - - opBloc.add( - FailOperation(message: "Notizen konnte nicht gelöscht werden."), - ); + debugPrint("Fehler beim Löschen der Notiz: $e $st"); + _handleError(e, "Notiz konnte nicht gelöscht werden"); } } } diff --git a/lib/feature/delivery/detail/presentation/steps/step_info.dart b/lib/feature/delivery/detail/presentation/steps/step_info.dart index f6de07f..bbbc3a2 100644 --- a/lib/feature/delivery/detail/presentation/steps/step_info.dart +++ b/lib/feature/delivery/detail/presentation/steps/step_info.dart @@ -8,6 +8,8 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../bloc/tour_bloc.dart'; import '../../../bloc/tour_state.dart'; +enum _StatusAction { hold, cancel, reactivate } + class DeliveryStepInfo extends StatefulWidget { final Delivery delivery; @@ -39,76 +41,73 @@ class _DeliveryStepInfo extends State { await launchUrl(url, mode: LaunchMode.externalApplication); } - Widget _deliveryStatusChangeActions() { - List actions = []; + Widget _statusOverflow() { + final state = widget.delivery.state; + final List> entries; - if (widget.delivery.state == DeliveryState.ongoing) { - actions = [ - Column( - children: [ - IconButton( - onPressed: () { - context.read().add( - HoldDeliveryEvent(deliveryId: widget.delivery.id), - ); - Navigator.of(context).pop(); - }, - icon: Icon( - Icons.change_circle, - color: Colors.orangeAccent, - size: 42, - ), - ), - Text("Zurückstellen"), - ], + if (state == DeliveryState.ongoing) { + entries = const [ + PopupMenuItem( + value: _StatusAction.hold, + child: Row( + children: [ + Icon(Icons.change_circle, color: Colors.orangeAccent), + SizedBox(width: 12), + Text("Zurückstellen"), + ], + ), ), - - Column( - children: [ - IconButton( - onPressed: () { - context.read().add( - CancelDeliveryEvent(deliveryId: widget.delivery.id), - ); - - Navigator.of(context).pop(); - }, - //style: IconButton.styleFrom(backgroundColor: Colors.red), - icon: Icon(Icons.cancel, color: Colors.red, size: 42), - ), - Text("Abbrechen"), - ], + PopupMenuItem( + value: _StatusAction.cancel, + child: Row( + children: [ + Icon(Icons.cancel, color: Colors.red), + SizedBox(width: 12), + Text("Abbrechen"), + ], + ), + ), + ]; + } else { + entries = const [ + PopupMenuItem( + value: _StatusAction.reactivate, + child: Row( + children: [ + Icon(Icons.published_with_changes, color: Colors.blueAccent), + SizedBox(width: 12), + Text("Reaktivieren"), + ], + ), ), ]; } - if (widget.delivery.state == DeliveryState.canceled || - widget.delivery.state == DeliveryState.onhold || - widget.delivery.state == DeliveryState.finished) { - actions = [ - Column( - children: [ - IconButton( - onPressed: () { - context.read().add( - ReactivateDeliveryEvent(deliveryId: widget.delivery.id), - ); - }, - icon: Icon( - Icons.published_with_changes, - color: Colors.blueAccent, - size: 42 - ), - ), - Text("Reaktivieren"), - ], - ), - ]; - } - - return Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: actions, + return PopupMenuButton<_StatusAction>( + icon: const Icon(Icons.more_vert), + tooltip: "Status ändern", + itemBuilder: (context) => entries, + onSelected: (action) { + switch (action) { + case _StatusAction.hold: + context.read().add( + HoldDeliveryEvent(deliveryId: widget.delivery.id), + ); + Navigator.of(context).pop(); + break; + case _StatusAction.cancel: + context.read().add( + CancelDeliveryEvent(deliveryId: widget.delivery.id), + ); + Navigator.of(context).pop(); + break; + case _StatusAction.reactivate: + context.read().add( + ReactivateDeliveryEvent(deliveryId: widget.delivery.id), + ); + break; + } + }, ); } @@ -119,55 +118,46 @@ class _DeliveryStepInfo extends State { color: Theme.of(context).colorScheme.onSecondary, child: Padding( padding: const EdgeInsets.all(10), - child: Column( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - children: [ - IconButton.filled( - onPressed: - widget.delivery.contactPerson?.phoneNumber != null - ? () async { + Expanded( + child: Builder( + builder: (context) { + final phone = widget.delivery.contactPerson?.phoneNumber; + final bool hasPhone = phone != null && phone.isNotEmpty; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton.filled( + onPressed: hasPhone + ? () async { await launchUrl( - Uri( - scheme: "tel", - path: - widget - .delivery - .contactPerson - ?.phoneNumber!, - ), + Uri(scheme: "tel", path: phone), ); } - : null, - icon: Icon(Icons.phone), - ), - Text("Anrufen"), - ], - ), - - Column( - children: [ - IconButton.filled( - onPressed: () { - _launchMapsUrl("google"); - }, - icon: Icon(Icons.map_outlined), - ), - Text("Google Maps"), - ], - ), - ], + : null, + icon: const Icon(Icons.phone), + ), + const Text("Anrufen"), + ], + ); + }, + ), ), - - const Padding( - padding: EdgeInsets.only(top: 10, bottom: 10), - child: Divider(), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton.filled( + onPressed: () => _launchMapsUrl("google"), + icon: const Icon(Icons.map_outlined), + ), + const Text("Google Maps"), + ], + ), ), - - _deliveryStatusChangeActions(), + _statusOverflow(), ], ), ), @@ -176,6 +166,16 @@ class _DeliveryStepInfo extends State { } Widget _customerInformation() { + final phone = widget.delivery.contactPerson?.phoneNumber; + final String phoneText = (phone != null && phone.isNotEmpty) + ? phone + : "keine Nummer angegeben"; + + final email = widget.delivery.customer.email; + final String emailText = (email != null && email.isNotEmpty) + ? email + : "keine E-Mail angegeben"; + return SizedBox( width: double.infinity, child: Card( @@ -228,9 +228,24 @@ class _DeliveryStepInfo extends State { Icon(Icons.phone, color: Theme.of(context).primaryColor), Padding( padding: const EdgeInsets.only(left: 10), - child: Text( - widget.delivery.contactPerson?.phoneNumber.toString() ?? - "", + child: Text(phoneText), + ), + ], + ), + ), + + Padding( + padding: const EdgeInsets.only(top: 15), + child: Row( + children: [ + Icon(Icons.mail, color: Theme.of(context).primaryColor), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 10), + child: Text( + emailText, + overflow: TextOverflow.ellipsis, + ), ), ), ], @@ -275,28 +290,66 @@ class _DeliveryStepInfo extends State { ); } - Widget _deliveryAgreements() { + Widget _agreementsAndDesiredTime() { String agreements = "keine Vereinbarungen getroffen!"; if (widget.delivery.specialAgreements != null && widget.delivery.specialAgreements != "") { agreements = widget.delivery.specialAgreements!; } + final desiredTime = widget.delivery.desiredTime; + final bool hasDesiredTime = desiredTime != null && desiredTime.isNotEmpty; + final primary = Theme.of(context).primaryColor; + return Card( color: Theme.of(context).colorScheme.onSecondary, child: Padding( padding: const EdgeInsets.all(10), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: EdgeInsets.all(15), - child: Icon( - Icons.warning, - color: Theme.of(context).primaryColor, - size: 28, + if (hasDesiredTime) ...[ + Row( + children: [ + Padding( + padding: const EdgeInsets.all(15), + child: Icon(Icons.schedule, color: primary, size: 28), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Wunschtermin", + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Text( + desiredTime, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: primary, + ), + ), + ], + ), + ), + ], ), + const Divider(height: 24), + ], + Row( + children: [ + Padding( + padding: const EdgeInsets.all(15), + child: Icon(Icons.warning, color: primary, size: 28), + ), + Expanded(child: Text(agreements)), + ], ), - Expanded(child: Text(agreements)), ], ), ), @@ -330,7 +383,7 @@ class _DeliveryStepInfo extends State { ), Padding( padding: const EdgeInsets.only(top: 10), - child: _deliveryAgreements(), + child: _agreementsAndDesiredTime(), ), Padding( diff --git a/lib/feature/delivery/detail/service/notes_service.dart b/lib/feature/delivery/detail/service/notes_service.dart index b55ca5a..20ef152 100644 --- a/lib/feature/delivery/detail/service/notes_service.dart +++ b/lib/feature/delivery/detail/service/notes_service.dart @@ -275,10 +275,14 @@ class NoteService { LocalDocuFrameConfiguration config = getConfig(); return urls.map((url) async { - return (await http.get( + final response = await http.get( Uri.parse("${config.backendUrl}$url"), headers: getSessionOrThrow(), - )).bodyBytes; + ); + if (response.statusCode == HttpStatus.unauthorized) { + throw UserUnauthorized(); + } + return response.bodyBytes; }).toList(); } catch (e, st) { debugPrint("An error occured:"); diff --git a/lib/feature/delivery/overview/presentation/delivery_info.dart b/lib/feature/delivery/overview/presentation/delivery_info.dart index e2122d6..a345668 100644 --- a/lib/feature/delivery/overview/presentation/delivery_info.dart +++ b/lib/feature/delivery/overview/presentation/delivery_info.dart @@ -1,80 +1,86 @@ import 'package:flutter/material.dart'; +import 'package:hl_lieferservice/model/delivery.dart' show DeliveryState; import 'package:hl_lieferservice/model/tour.dart'; import 'package:intl/intl.dart'; class DeliveryInfo extends StatelessWidget { final Tour tour; + final int? selectedCarId; - const DeliveryInfo({super.key, required this.tour}); + const DeliveryInfo({super.key, required this.tour, this.selectedCarId}); @override Widget build(BuildContext context) { - String date = DateFormat("dd.MM.yyyy").format(tour.date); - String amountDeliveries = tour.deliveries.length.toString(); + final String date = DateFormat("dd.MM.yyyy").format(tour.date); + final relevantDeliveries = selectedCarId != null + ? tour.deliveries.where((d) => d.carId == selectedCarId).toList() + : tour.deliveries; + final total = relevantDeliveries.length; + final done = relevantDeliveries + .where((d) => d.state == DeliveryState.finished) + .length; + final progress = total > 0 ? done / total : 0.0; + final allDone = total > 0 && done == total; return Padding( - padding: const EdgeInsets.all(10), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Text( - "Informationen", - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - - SizedBox( - width: double.infinity, - child: Card( - color: Theme.of(context).colorScheme.onSecondary, - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 15), + child: SizedBox( + width: double.infinity, + child: Card( + color: Theme.of(context).colorScheme.onSecondary, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - Icon(Icons.calendar_month), - Padding( - padding: const EdgeInsets.only(left: 5), - child: Text("Datum"), - ), - ], + const Icon(Icons.calendar_month), + const Padding( + padding: EdgeInsets.only(left: 5), + child: Text("Datum"), ), - Text(date), ], ), - - Padding( - padding: const EdgeInsets.only(top: 15), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon(Icons.local_shipping_outlined), - Padding( - padding: const EdgeInsets.only(left: 5), - child: Text("Lieferungen"), - ), - ], - ), - Text(amountDeliveries), - ], - ), - ), + Text(date), ], ), - ), + const SizedBox(height: 15), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.local_shipping_outlined), + const Padding( + padding: EdgeInsets.only(left: 5), + child: Text("Lieferungen"), + ), + ], + ), + Text("$done / $total"), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + minHeight: 6, + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation( + allDone ? Colors.green : Theme.of(context).primaryColor, + ), + ), + ), + ], ), ), - ], + ), ), ); } diff --git a/lib/feature/delivery/overview/presentation/delivery_item.dart b/lib/feature/delivery/overview/presentation/delivery_item.dart index e6a8a72..cda2de4 100644 --- a/lib/feature/delivery/overview/presentation/delivery_item.dart +++ b/lib/feature/delivery/overview/presentation/delivery_item.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; import 'package:hl_lieferservice/model/delivery.dart'; import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart'; @@ -18,60 +19,132 @@ class DeliveryListItem extends StatelessWidget { required this.distance, }); - Widget _leading(BuildContext context) { - if (delivery.state == DeliveryState.finished) { - return Icon(Icons.check_circle, color: Colors.green); - } - - if (delivery.state == DeliveryState.canceled) { - return Icon(Icons.cancel_rounded, color: Colors.red); - } - - if (delivery.state == DeliveryState.onhold) { - return Icon(Icons.pause_circle, color: Colors.orange); - } - - return Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Icon(Icons.location_on, color: Theme.of(context).primaryColor), - Text("${distance.toStringAsFixed(2)}km"), - ], - ); - } - void _goToDelivery(BuildContext context) { Navigator.of(context).push( MaterialPageRoute( - builder: - (context) => BlocProvider( - create: - (context) => NoteBloc( - deliveryId: delivery.id, - opBloc: context.read(), - repository: NoteRepository( - service: NoteService(), - ), - ), - - child: DeliveryDetail(deliveryId: delivery.id), - ), + builder: (context) => BlocProvider( + create: (context) => NoteBloc( + deliveryId: delivery.id, + opBloc: context.read(), + authBloc: context.read(), + repository: NoteRepository(service: NoteService()), + ), + child: DeliveryDetail(deliveryId: delivery.id), + ), ), ); } + (Color, Color, IconData, String) _stateStyle(BuildContext context) { + switch (delivery.state) { + case DeliveryState.finished: + return ( + Colors.green.withValues(alpha: 0.07), + Colors.green.withValues(alpha: 0.35), + Icons.check_circle_rounded, + "Abgeschlossen", + ); + case DeliveryState.canceled: + return ( + Colors.red.withValues(alpha: 0.07), + Colors.red.withValues(alpha: 0.35), + Icons.cancel_rounded, + "Storniert", + ); + case DeliveryState.onhold: + return ( + Colors.orange.withValues(alpha: 0.07), + Colors.orange.withValues(alpha: 0.35), + Icons.pause_circle_rounded, + "Pausiert", + ); + case DeliveryState.ongoing: + return ( + Theme.of(context).colorScheme.surfaceContainerLow, + Colors.transparent, + Icons.local_shipping_outlined, + "${distance.toStringAsFixed(1)} km", + ); + } + } + @override Widget build(BuildContext context) { - return ListTile( - title: Text( - delivery.customer.name, - style: Theme.of(context).textTheme.titleMedium, + final (cardColor, borderColor, icon, statusLabel) = _stateStyle(context); + final isOngoing = delivery.state == DeliveryState.ongoing; + + final iconColor = switch (delivery.state) { + DeliveryState.finished => Colors.green, + DeliveryState.canceled => Colors.red, + DeliveryState.onhold => Colors.orange, + DeliveryState.ongoing => Theme.of(context).primaryColor, + }; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + elevation: 0, + color: cardColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: borderColor), + ), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _goToDelivery(context), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(icon, color: iconColor, size: 28), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + delivery.customer.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: isOngoing ? null : iconColor, + ), + ), + const SizedBox(height: 2), + Text( + delivery.customer.address.toString(), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + statusLabel, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isOngoing + ? Theme.of(context).colorScheme.onSurfaceVariant + : iconColor, + ), + ), + const SizedBox(height: 4), + Icon( + Icons.arrow_forward_ios, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ], + ), + ), ), - leading: _leading(context), - tileColor: Theme.of(context).colorScheme.surfaceContainerHigh, - subtitle: Text(delivery.customer.address.toString()), - trailing: Icon(Icons.arrow_forward_ios), - onTap: () => _goToDelivery(context), ); } } diff --git a/lib/feature/delivery/overview/presentation/delivery_list.dart b/lib/feature/delivery/overview/presentation/delivery_list.dart index fdfe88a..5d50c6e 100644 --- a/lib/feature/delivery/overview/presentation/delivery_list.dart +++ b/lib/feature/delivery/overview/presentation/delivery_list.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; -import 'package:hl_lieferservice/feature/delivery/overview/model/sorting_information.dart'; + import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart'; import 'package:hl_lieferservice/model/delivery.dart'; @@ -56,79 +56,66 @@ class _DeliveryListState extends State { builder: (context, state) { final currentState = state; if (currentState is TourLoaded) { - List deliveries = - currentState.tour.deliveries - .where( - (delivery) => - delivery.carId == widget.selectedCarId && - delivery.allArticlesScanned() && - delivery.state != DeliveryState.finished, - ) - .toList(); + if (widget.sortType == SortType.custom) { + return _showCustomSortedList( + currentState.tour.deliveries, + currentState.sortingInformation[widget.selectedCarId.toString()] ?? [], + currentState.distances ?? {}, + ); + } - List finishedDeliveries = - currentState.tour.deliveries - .where( - (delivery) => - delivery.state == DeliveryState.finished && - delivery.carId == widget.selectedCarId, - ) - .toList(); + final allDeliveries = currentState.tour.deliveries + .where((d) => d.carId == widget.selectedCarId) + .toList(); - if (deliveries.isEmpty) { + if (allDeliveries.isEmpty) { return ListView( - physics: NeverScrollableScrollPhysics(), + physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, - children: [ - Center(child: const Text("Keine Auslieferungen gefunden")), + children: const [ + Center(child: Text("Keine Auslieferungen gefunden")), ], ); } + final ongoing = allDeliveries + .where((d) => d.state == DeliveryState.ongoing) + .toList(); + final nonOngoing = allDeliveries + .where((d) => d.state != DeliveryState.ongoing) + .toList(); + + int Function(Delivery, Delivery) comparator; switch (widget.sortType) { - case SortType.custom: - return _showCustomSortedList( - currentState.tour.deliveries, - currentState.sortingInformation[widget.selectedCarId.toString()] ?? [], - currentState.distances ?? {}, - ); - case SortType.nameAsc: - deliveries.sort( - (a, b) => a.customer.name.compareTo(b.customer.name), - ); + comparator = (a, b) => a.customer.name.compareTo(b.customer.name); break; - case SortType.nameDesc: - deliveries.sort( - (a, b) => b.customer.name.compareTo(a.customer.name), - ); + comparator = (a, b) => b.customer.name.compareTo(a.customer.name); break; - case SortType.distance: - deliveries.sort( - (a, b) => (currentState.distances![a.id] ?? 0.0).compareTo( - currentState.distances![b.id] ?? 0.0, - ), - ); + comparator = (a, b) => + (currentState.distances?[a.id] ?? 0.0) + .compareTo(currentState.distances?[b.id] ?? 0.0); break; + default: + comparator = (a, b) => a.customer.name.compareTo(b.customer.name); } - //deliveries.addAll(finishedDeliveries); + ongoing.sort(comparator); + nonOngoing.sort(comparator); - return ListView.separated( - separatorBuilder: (context, index) => const Divider(height: 0), + final sorted = [...ongoing, ...nonOngoing]; + + return ListView.builder( shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - Delivery delivery = deliveries[index]; - - return DeliveryListItem( - delivery: delivery, - distance: currentState.distances?[delivery.id] ?? 0.0, - ); - }, - itemCount: deliveries.length, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 8), + itemCount: sorted.length, + itemBuilder: (context, index) => DeliveryListItem( + delivery: sorted[index], + distance: currentState.distances?[sorted[index].id] ?? 0.0, + ), ); } diff --git a/lib/feature/delivery/overview/presentation/delivery_overview.dart b/lib/feature/delivery/overview/presentation/delivery_overview.dart index 73ea6c7..8c48c3a 100644 --- a/lib/feature/delivery/overview/presentation/delivery_overview.dart +++ b/lib/feature/delivery/overview/presentation/delivery_overview.dart @@ -1,5 +1,7 @@ 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/state.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/overview/presentation/delivery_info.dart'; @@ -34,8 +36,14 @@ class _DeliveryOverviewState extends State { void initState() { super.initState(); - // Select the first car for initialization - _selectedCarId = widget.tour.driver.cars.firstOrNull?.id; + // Pre-select today's car from the daily car selection. + // Falls back to the first available car if no selection exists. + final carSelectState = context.read().state; + if (carSelectState is CarSelectComplete) { + _selectedCarId = carSelectState.selectedCar.id; + } else { + _selectedCarId = widget.tour.driver.cars.firstOrNull?.id; + } _sortType = SortType.nameAsc; } @@ -44,54 +52,6 @@ class _DeliveryOverviewState extends State { context.read().add(LoadTour(teamId: state.user.number)); } - Widget _carSelection() { - return SizedBox( - width: double.infinity, - height: 50, - child: ListView( - scrollDirection: Axis.horizontal, - children: - widget.tour.driver.cars.map((car) { - Color? backgroundColor; - Color? iconColor = Theme.of(context).primaryColor; - Color? textColor; - - if (_selectedCarId == car.id) { - backgroundColor = Theme.of(context).primaryColor; - textColor = Theme.of(context).colorScheme.onSecondary; - iconColor = Theme.of(context).colorScheme.onSecondary; - } - - return Padding( - padding: const EdgeInsets.only(right: 8), - child: GestureDetector( - onTap: () { - setState(() { - _selectedCarId = car.id; - }); - }, - child: Chip( - backgroundColor: backgroundColor, - label: Row( - children: [ - Icon(Icons.local_shipping, color: iconColor, size: 20), - Padding( - padding: const EdgeInsets.only(left: 5), - child: Text( - car.plate, - style: TextStyle(color: textColor, fontSize: 12), - ), - ), - ], - ), - ), - ), - ); - }).toList(), - ), - ); - } - /// Highlight the text of the active sorting type. TextStyle? _popupItemTextStyle() { return TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold); @@ -99,17 +59,23 @@ class _DeliveryOverviewState extends State { @override Widget build(BuildContext context) { - return RefreshIndicator( + return BlocListener( + listener: (context, carState) { + if (carState is CarSelectComplete) { + setState(() => _selectedCarId = carState.selectedCar.id); + } + }, + child: RefreshIndicator( onRefresh: _loadTour, child: ListView( //crossAxisAlignment: CrossAxisAlignment.start, children: [ - DeliveryInfo(tour: widget.tour), + DeliveryInfo(tour: widget.tour, selectedCarId: _selectedCarId), Padding( padding: const EdgeInsets.only( left: 10, right: 10, - top: 15, + top: 0, bottom: 10, ), child: Row( @@ -191,16 +157,13 @@ class _DeliveryOverviewState extends State { ], ), ), - Padding( - padding: const EdgeInsets.only(left: 10, right: 10, bottom: 20), - child: _carSelection(), - ), DeliveryList( selectedCarId: _selectedCarId, sortType: _sortType, ), ], ), + ), ); } } diff --git a/lib/feature/delivery/overview/presentation/delivery_overview_page.dart b/lib/feature/delivery/overview/presentation/delivery_overview_page.dart index f37dfd8..14aec63 100644 --- a/lib/feature/delivery/overview/presentation/delivery_overview_page.dart +++ b/lib/feature/delivery/overview/presentation/delivery_overview_page.dart @@ -1,5 +1,7 @@ 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/state.dart'; import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart'; import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart'; @@ -16,25 +18,55 @@ class DeliveryOverviewPage extends StatefulWidget { class _DeliveryOverviewPageState extends State { @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (state is TourLoaded) { - final currentState = state; + final carState = context.watch().state; - return Center( - child: DeliveryOverview( - tour: currentState.tour, - distances: currentState.distances ?? {}, + return Scaffold( + appBar: AppBar( + title: const Text("Auslieferung"), + centerTitle: false, + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + actions: [ + if (carState is CarSelectComplete) + Padding( + padding: const EdgeInsets.only(right: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.local_shipping, + color: Theme.of(context).colorScheme.onSecondary, + size: 20, + ), + const SizedBox(width: 6), + Text( + carState.selectedCar.plate, + style: TextStyle( + color: Theme.of(context).colorScheme.onSecondary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), ), - ); - } + ], + ), + body: BlocBuilder( + builder: (context, state) { + if (state is TourLoaded) { + return DeliveryOverview( + tour: state.tour, + distances: state.distances ?? {}, + ); + } - if (state is TourLoadingFailed) { - return DeliveryLoadingFailedPage(); - } + if (state is TourLoadingFailed) { + return DeliveryLoadingFailedPage(); + } - return Container(); - }, + return const Center(child: CircularProgressIndicator()); + }, + ), ); } } diff --git a/lib/feature/delivery/repository/tour_repository.dart b/lib/feature/delivery/repository/tour_repository.dart index 500dd9c..abc5724 100644 --- a/lib/feature/delivery/repository/tour_repository.dart +++ b/lib/feature/delivery/repository/tour_repository.dart @@ -80,6 +80,8 @@ class TourRepository { if (article.scannedAmount < article.amount) { article.scannedAmount += 1; + delivery.carId = int.tryParse(carId) ?? delivery.carId; + await service.assignCar(deliveryId, carId); _tourStream.add(tour); return ScanResult.scanned; } else { diff --git a/lib/feature/delivery/service/tour_service.dart b/lib/feature/delivery/service/tour_service.dart index a199236..edc2483 100644 --- a/lib/feature/delivery/service/tour_service.dart +++ b/lib/feature/delivery/service/tour_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:hl_lieferservice/dto/delivery_response.dart'; import 'package:hl_lieferservice/dto/delivery_update.dart'; import 'package:hl_lieferservice/dto/delivery_update_response.dart'; @@ -271,10 +272,24 @@ class TourService { Future finishDelivery(String deliveryId) async { try { + // ISO-8601 mit T-Separator: sprachunabhaengig fuer SQL-Server datetime. + // ('yyyy-MM-dd HH:mm:ss' OHNE T wird unter DATEFORMAT=DMY (DE) als YDM + // geparst und schlaegt fuer Tag > 12 fehl.) + // ERPframe arbeitet mit lokaler Zeit -> bewusst keine UTC-Konvertierung. + final String deliveredAt = DateFormat( + "yyyy-MM-dd'T'HH:mm:ss", + ).format(DateTime.now()); + + var headers = {"Content-Type": "application/json"}; + headers.addAll(getSessionOrThrow()); + var response = await post( urlBuilder("_web_finishDelivery"), - headers: getSessionOrThrow(), - body: {"delivery_id": deliveryId}, + headers: headers, + body: jsonEncode({ + "delivery_id": deliveryId, + "delivered_at": deliveredAt, + }), ); if (response.statusCode == HttpStatus.unauthorized) { diff --git a/lib/feature/scan/presentation/scan_page.dart b/lib/feature/scan/presentation/scan_page.dart index dc21713..5dca90a 100644 --- a/lib/feature/scan/presentation/scan_page.dart +++ b/lib/feature/scan/presentation/scan_page.dart @@ -1,277 +1,794 @@ +import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.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/state.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/overview/presentation/delivery_fail_page.dart'; -import 'package:hl_lieferservice/feature/scan/presentation/scan_screen.dart'; +import 'package:hl_lieferservice/feature/scan/presentation/scanner.dart'; +import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart'; +import 'package:hl_lieferservice/feature/settings/bloc/settings_state.dart'; +import 'package:hl_lieferservice/model/article.dart'; import 'package:hl_lieferservice/model/delivery.dart'; import 'package:hl_lieferservice/model/tour.dart'; import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart'; import 'package:hl_lieferservice/widget/home/bloc/navigation_event.dart'; +import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; +import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; -enum TourHomeSteps { planning, delivery, off } +// --------------------------------------------------------------------------- +// Data helpers +// --------------------------------------------------------------------------- + +class _ArticleDeliveryEntry { + final Delivery delivery; + final Article article; + final String? carPlate; + + const _ArticleDeliveryEntry({ + required this.delivery, + required this.article, + this.carPlate, + }); +} + +class _ArticleGroup { + final String articleNumber; + final String name; + final int totalAmount; + final int totalScanned; + final int totalRemoved; + final List<_ArticleDeliveryEntry> entries; + + bool get isComplete => totalScanned + totalRemoved >= totalAmount; + int get scannedOrRemoved => totalScanned + totalRemoved; + + const _ArticleGroup({ + required this.articleNumber, + required this.name, + required this.totalAmount, + required this.totalScanned, + required this.totalRemoved, + required this.entries, + }); +} + +// --------------------------------------------------------------------------- +// ScanPage +// --------------------------------------------------------------------------- class ScanPage extends StatefulWidget { const ScanPage({super.key}); @override - State createState() => _ScanPageState(); + State createState() => _ScanPageState(); } -class _ScanPageState extends State { - int _currentStepIndex = 1; +class _ScanPageState extends State with SingleTickerProviderStateMixin { + late final TabController _tabController; + final FocusNode _focusNode = FocusNode(); + String _buffer = ''; + Timer? _bufferTimer; + int? _selectedCarId; + bool _isScanning = false; @override void initState() { super.initState(); + _tabController = TabController(length: 2, vsync: this); + WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus()); - _tryFinish(context.read().state); + final carState = context.read().state; + if (carState is CarSelectComplete) { + _selectedCarId = carState.selectedCar.id; + } } - void _onStartScan() { - Navigator.of( - context, - ).push(MaterialPageRoute(builder: (context) => ArticleScanningScreen())); + @override + void dispose() { + _tabController.dispose(); + _focusNode.dispose(); + _bufferTimer?.cancel(); + super.dispose(); } - Widget _tourSteps(Tour tour) { - var allArticlesScanned = tour.deliveries.every( - (delivery) => - delivery.allArticlesScanned() || - delivery.state == DeliveryState.finished, - ); + // ------------------------------------------------------------------------- + // Scanner input + // ------------------------------------------------------------------------- - return Stepper( - currentStep: _currentStepIndex, - controlsBuilder: (context, details) { - if (details.stepIndex == TourHomeSteps.planning.index) { - return Container( - alignment: Alignment.center, - child: Padding( - padding: const EdgeInsets.only(top: 15), - child: FilledButton.icon( - label: const Text("Scannen"), - onPressed: _onStartScan, - icon: const Icon(Icons.qr_code), - ), - ), - ); - } else { - return Container( - alignment: Alignment.center, - child: Padding( - padding: const EdgeInsets.only(top: 15), - child: FilledButton.icon( - label: const Text("Auslieferung starten"), - onPressed: - allArticlesScanned - ? () => context.read().add( - NavigateToIndex(index: 1), - ) - : null, - icon: const Icon(Icons.local_shipping), - ), - ), - ); - } - }, - onStepContinue: - _currentStepIndex >= 1 - ? null - : () => setState(() { - if (_currentStepIndex < 2) { - _currentStepIndex += 1; - } - }), - onStepCancel: - _currentStepIndex == 0 - ? null - : () => setState(() { - if (_currentStepIndex > 0) { - _currentStepIndex -= 1; - } - }), - onStepTapped: - (value) => setState(() { - if (_currentStepIndex == 1 && allArticlesScanned) { - return; - } + void _handleKey(KeyEvent event) { + if (event is! KeyDownEvent) return; - _currentStepIndex = value; - }), - steps: [ - Step( - title: Row( - children: [ - Text( - "Fahrzeuge beladen", - style: TextStyle( - color: allArticlesScanned ? Colors.grey : null, - ), - ), - Padding( - padding: const EdgeInsets.only(left: 5), - child: - !allArticlesScanned - ? const Icon( - Icons.access_time_filled, - color: Colors.orangeAccent, - ) - : const Icon( - Icons.check_circle, - color: Colors.lightGreen, - ), - ), - ], - ), - content: const Column( - children: [ - Padding( - padding: EdgeInsets.only(bottom: 10), - child: Icon(Icons.barcode_reader, color: Colors.black), - ), - Text( - "Scannen Sie die Ware, die Sie für die Auslieferungen benötigen.", - ), - ], - ), - ), - Step( - title: const Text("Ausliefern"), - content: Container( - alignment: Alignment.centerLeft, - child: - !allArticlesScanned - ? const Text( - "Scannen Sie erst die benötigte Ware, um die Auslieferungen zu beginnen.", - ) - : null, - ), - ), - ], - ); - } - - Widget _info(Tour tour) { - int amountArticles = tour.deliveries.fold( - 0, - (acc, delivery) => - acc + - delivery.articles - .where((article) => article.scannable) - .fold( - 0, - (amountArticles, article) => amountArticles + article.amount, - ), - ); - - int amountCars = tour.driver.cars.length; - int amountDeliveries = tour.deliveries.length; - - return Padding( - padding: const EdgeInsets.all(10), - child: SizedBox( - width: double.infinity, - child: Card( - color: Theme.of(context).colorScheme.onSecondary, - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Padding( - padding: const EdgeInsets.only(top: 0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon(Icons.archive), - Padding( - padding: const EdgeInsets.only(left: 5), - child: Text("Anzahl Artikel"), - ), - ], - ), - Text(amountArticles.toString()), - ], - ), - ), - - Padding( - padding: const EdgeInsets.only(top: 15), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon(Icons.local_shipping_outlined), - Padding( - padding: const EdgeInsets.only(left: 5), - child: Text("Anzahl Fahrzeuge"), - ), - ], - ), - Text(amountCars.toString()), - ], - ), - ), - - Padding( - padding: const EdgeInsets.only(top: 15), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon(Icons.person), - Padding( - padding: const EdgeInsets.only(left: 5), - child: Text("Anzahl Lieferungen"), - ), - ], - ), - Text(amountDeliveries.toString()), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } - - void _tryFinish(TourState state) { - if (state is TourLoaded) { - if (!state.tour.deliveries - .where((delivery) => delivery.state == DeliveryState.ongoing) - .every((delivery) => delivery.allArticlesScanned())) { - setState(() { - _currentStepIndex = 0; + if (event.logicalKey == LogicalKeyboardKey.enter) { + _bufferTimer?.cancel(); + if (_buffer.isNotEmpty) { + _handleBarcodeScanned(_buffer); + _buffer = ''; + } + } else { + final character = event.character; + if (character != null && character.isNotEmpty) { + _buffer += character; + _bufferTimer?.cancel(); + _bufferTimer = Timer(const Duration(milliseconds: 1000), () { + if (_buffer.isNotEmpty) { + _handleBarcodeScanned(_buffer); + _buffer = ''; + } }); } } } - @override - Widget build(BuildContext context) { - return BlocConsumer( - listener: (context, state) { - _tryFinish(state); - }, - builder: (context, state) { - if (state is TourLoaded) { - return Column(children: [_info(state.tour), _tourSteps(state.tour)]); - } + /// Extrahiert die Artikelnummer aus einem Barcode der Form + /// `;;`. + /// Liefert `null`, wenn der Barcode dem erwarteten Format nicht entspricht. + String? _extractArticleNumber(String barcode) { + final parts = barcode.split(';'); + if (parts.length != 3) return null; + final articleNumber = parts[0].trim(); + if (articleNumber.isEmpty) return null; + return articleNumber; + } - if (state is TourLoadingFailed) { - return DeliveryLoadingFailedPage(); - } + void _handleBarcodeScanned(String barcode) { + if (!mounted) return; - return Center(child: CircularProgressIndicator()); + if (_selectedCarId == null) { + context.read().add( + FailOperation(message: "Kein Fahrzeug ausgewählt"), + ); + return; + } + + final articleNumber = _extractArticleNumber(barcode); + if (articleNumber == null) { + context.read().add( + FailOperation(message: "Ungültiger Barcode: $barcode"), + ); + return; + } + + final tourState = context.read().state; + if (tourState is! TourLoaded) return; + + final needingDeliveries = tourState.tour.deliveries + .where((d) => d.state != DeliveryState.finished) + .where((d) => d.articles.any((a) => + a.articleNumber == articleNumber && + a.scannedAmount + a.scannedRemovedAmount < a.amount)) + .toList(); + + if (needingDeliveries.isEmpty) { + setState(() => _isScanning = true); + context.read().add(ScanArticleEvent( + articleNumber: articleNumber, + carId: _selectedCarId!.toString(), + deliveryId: tourState.tour.deliveries.first.id, + )); + return; + } + + if (needingDeliveries.length == 1) { + setState(() => _isScanning = true); + context.read().add(ScanArticleEvent( + articleNumber: articleNumber, + carId: _selectedCarId!.toString(), + deliveryId: needingDeliveries.first.id, + )); + return; + } + + _showCustomerSelectionSheet(articleNumber, needingDeliveries, tourState.tour); + } + + void _showCustomerSelectionSheet( + String articleNumber, + List deliveries, + Tour tour, + ) { + final tourBloc = context.read(); + final carId = _selectedCarId!; + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Icon(Icons.help_outline, size: 20), + const SizedBox(width: 8), + Text( + "Für welchen Kunden?", + style: Theme.of(ctx).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + const Divider(height: 1), + ...deliveries.map((delivery) { + final carPlate = _lookupCarPlate(delivery.carId, tour); + return ListTile( + leading: const Icon(Icons.person_outline), + title: Text(delivery.customer.name), + subtitle: Text( + delivery.customer.address.toString(), + style: const TextStyle(fontSize: 12), + ), + trailing: carPlate != null ? _carBadge(ctx, carPlate) : null, + onTap: () { + Navigator.pop(ctx); + setState(() => _isScanning = true); + tourBloc.add(ScanArticleEvent( + articleNumber: articleNumber, + carId: carId.toString(), + deliveryId: delivery.id, + )); + }, + ); + }), + const SizedBox(height: 8), + ], + ), + ); }, ); } -} \ No newline at end of file + + // ------------------------------------------------------------------------- + // Data + // ------------------------------------------------------------------------- + + String? _lookupCarPlate(int? carId, Tour tour) { + if (carId == null) return null; + return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate; + } + + List<_ArticleGroup> _buildArticleGroups(Tour tour) { + final Map> grouped = {}; + + for (final delivery in tour.deliveries) { + if (delivery.state == DeliveryState.finished) continue; + final carPlate = _lookupCarPlate(delivery.carId, tour); + for (final article in delivery.articles) { + if (!article.scannable) continue; + grouped.putIfAbsent(article.articleNumber, () => []); + grouped[article.articleNumber]!.add( + _ArticleDeliveryEntry( + delivery: delivery, + article: article, + carPlate: carPlate, + ), + ); + } + } + + return grouped.entries.map((e) { + final entries = e.value; + return _ArticleGroup( + articleNumber: e.key, + name: entries.first.article.name, + totalAmount: entries.fold(0, (sum, e) => sum + e.article.amount), + totalScanned: entries.fold(0, (sum, e) => sum + e.article.scannedAmount), + totalRemoved: entries.fold(0, (sum, e) => sum + e.article.scannedRemovedAmount), + entries: entries, + ); + }).toList() + ..sort((a, b) => a.name.compareTo(b.name)); + } + + // ------------------------------------------------------------------------- + // Widgets + // ------------------------------------------------------------------------- + + Widget _carBadge(BuildContext context, String plate) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.local_shipping_outlined, + size: 12, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 4), + Text( + plate, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ], + ), + ); + } + + Widget _buildProgressHeader(List<_ArticleGroup> allGroups) { + final total = allGroups.length; + final done = allGroups.where((g) => g.isComplete).length; + final progress = total > 0 ? done / total : 0.0; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Beladungsfortschritt", + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Text( + "$done / $total Artikel", + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + minHeight: 6, + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation( + done == total && total > 0 + ? Colors.green + : Theme.of(context).primaryColor, + ), + ), + ), + ], + ), + ); + } + + Widget _buildArticleTile(_ArticleGroup group, {int? carIdFilter}) { + final isComplete = group.isComplete; + final isPartial = group.scannedOrRemoved > 0 && !isComplete; + final entries = carIdFilter != null + ? group.entries + .where((e) => e.delivery.carId == carIdFilter) + .toList() + : group.entries; + + final Color cardColor; + final Color borderColor; + final Color titleColor; + final Color leadingColor; + + if (isComplete) { + cardColor = Colors.green.withValues(alpha: 0.07); + borderColor = Colors.green.withValues(alpha: 0.35); + titleColor = Colors.green.shade700; + leadingColor = Colors.green; + } else if (isPartial) { + cardColor = Colors.orange.withValues(alpha: 0.07); + borderColor = Colors.orange.withValues(alpha: 0.35); + titleColor = Colors.orange.shade800; + leadingColor = Colors.orange.shade700; + } else { + cardColor = Theme.of(context).colorScheme.surfaceContainerLow; + borderColor = Colors.transparent; + titleColor = Theme.of(context).colorScheme.onSurface; + leadingColor = Theme.of(context).colorScheme.onSurfaceVariant; + } + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + elevation: 0, + color: cardColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: borderColor), + ), + child: ExpansionTile( + shape: const Border(), + collapsedShape: const Border(), + leading: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: isComplete + ? Icon( + Icons.check_circle_rounded, + color: leadingColor, + size: 32, + key: const ValueKey('done'), + ) + : SizedBox( + width: 32, + key: const ValueKey('progress'), + child: Center( + child: Text( + '${group.scannedOrRemoved}/${group.totalAmount}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: leadingColor, + ), + ), + ), + ), + ), + title: Text( + group.name, + style: TextStyle( + fontWeight: FontWeight.w600, + color: titleColor, + ), + ), + subtitle: Text( + "Artikelnr. ${group.articleNumber}", + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + children: [ + const Divider(height: 1, indent: 16, endIndent: 16), + ...entries.map(_buildDeliveryEntry), + const SizedBox(height: 4), + ], + ), + ); + } + + Widget _buildDeliveryEntry(_ArticleDeliveryEntry entry) { + final article = entry.article; + final customer = entry.delivery.customer; + final entryDone = + article.scannedAmount + article.scannedRemovedAmount >= article.amount; + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 2), + leading: Icon( + entryDone ? Icons.check_circle_outline : Icons.person_outline, + color: entryDone + ? Colors.green + : Theme.of(context).colorScheme.onSurfaceVariant, + size: 20, + ), + title: Text( + customer.name, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + subtitle: Text( + customer.address.toString(), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + trailing: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (entry.carPlate != null) ...[ + _carBadge(context, entry.carPlate!), + const SizedBox(height: 4), + ], + Text( + '${article.scannedAmount + article.scannedRemovedAmount} / ${article.amount}×', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: entryDone + ? Colors.green + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + // ------------------------------------------------------------------------- + // Tab views + // ------------------------------------------------------------------------- + + Widget _buildOpenTab( + TourLoaded state, + List<_ArticleGroup> openGroups, + List<_ArticleGroup> allGroups, + bool useHardwareScanner, + ) { + return Column( + children: [ + if (_isScanning) + const LinearProgressIndicator(), + if (!useHardwareScanner && openGroups.isNotEmpty) + BarcodeScannerWidget(onBarcodeDetected: _handleBarcodeScanned), + _buildProgressHeader(allGroups), + const Divider(height: 1), + Expanded( + child: openGroups.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle_rounded, + size: 64, + color: Colors.green.shade400, + ), + const SizedBox(height: 12), + const Text( + "Alle Artikel geladen!", + style: TextStyle(fontSize: 16), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.only(top: 8, bottom: 96), + itemCount: openGroups.length, + itemBuilder: (context, index) => + _buildArticleTile(openGroups[index]), + ), + ), + ], + ); + } + + Widget _buildLoadedTab(List<_ArticleGroup> loadedGroups) { + if (_selectedCarId == null) { + return const Center(child: Text("Kein Fahrzeug ausgewählt")); + } + + if (loadedGroups.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.inventory_2_outlined, + size: 64, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + "Noch keine Artikel im Auto", + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.only(top: 8, bottom: 96), + itemCount: loadedGroups.length, + itemBuilder: (context, index) => _buildArticleTile( + loadedGroups[index], + carIdFilter: _selectedCarId, + ), + ); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, carState) { + if (carState is CarSelectComplete) { + setState(() => _selectedCarId = carState.selectedCar.id); + } + }, + builder: (context, carState) { + return BlocConsumer( + listener: (context, tourState) { + if (tourState is TourLoaded) { + setState(() => _isScanning = false); + } + }, + builder: (context, tourState) { + if (tourState is TourLoadingFailed) { + return const DeliveryLoadingFailedPage(); + } + + if (tourState is! TourLoaded) { + return const Center(child: CircularProgressIndicator()); + } + + final settingsState = context.read().state; + final useHardwareScanner = settingsState is AppSettingsLoaded && + settingsState.settings.useHardwareScanner; + + if (settingsState is AppSettingsFailed) { + context.read().add(FailOperation( + message: + "Einstellungen konnten nicht geladen werden. Nutze Kamera-Scanner.", + )); + } + + final allGroups = _buildArticleGroups(tourState.tour); + + // Offen: mindestens ein Kundeneintrag ist noch nicht vollständig gescannt + final openGroups = allGroups.where((g) => g.entries.any((e) => + e.article.scannedAmount + e.article.scannedRemovedAmount < + e.article.amount, + )).toList(); + + // Im Auto: mindestens ein Kundeneintrag für das aktuelle Auto ist vollständig + final loadedGroups = allGroups.where((g) => g.entries.any((e) => + e.delivery.carId == _selectedCarId && + e.article.scannedAmount + e.article.scannedRemovedAmount >= + e.article.amount, + )).toList(); + + final allDone = tourState.tour.deliveries.isNotEmpty && + openGroups.isEmpty; + + return Scaffold( + appBar: AppBar( + title: const Text("Beladung"), + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + centerTitle: false, + actions: [ + if (carState is CarSelectComplete) + Padding( + padding: const EdgeInsets.only(right: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.local_shipping, + color: Theme.of(context).colorScheme.onSecondary, + size: 20, + ), + const SizedBox(width: 6), + Text( + carState.selectedCar.plate, + style: TextStyle( + color: + Theme.of(context).colorScheme.onSecondary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + bottom: TabBar( + controller: _tabController, + labelColor: Theme.of(context).colorScheme.onSecondary, + unselectedLabelColor: Theme.of(context) + .colorScheme + .onSecondary + .withValues(alpha: 0.6), + indicatorColor: Theme.of(context).colorScheme.onSecondary, + tabs: [ + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.pending_outlined, size: 18), + const SizedBox(width: 6), + const Text("Offen"), + if (openGroups.isNotEmpty) ...[ + const SizedBox(width: 6), + _tabBadge( + context, + openGroups.length.toString(), + ), + ], + ], + ), + ), + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.local_shipping_outlined, size: 18), + const SizedBox(width: 6), + const Text("Im Auto"), + if (loadedGroups.isNotEmpty) ...[ + const SizedBox(width: 6), + _tabBadge( + context, + loadedGroups.length.toString(), + color: Colors.green, + ), + ], + ], + ), + ), + ], + ), + ), + floatingActionButton: allDone + ? FloatingActionButton.extended( + onPressed: () { + context + .read() + .add(NavigateToIndex(index: 1)); + }, + icon: const Icon(Icons.local_shipping_outlined), + label: const Text("Tour starten"), + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ) + : null, + body: KeyboardListener( + focusNode: _focusNode, + onKeyEvent: _handleKey, + child: TabBarView( + controller: _tabController, + children: [ + _buildOpenTab( + tourState, + openGroups, + allGroups, + useHardwareScanner, + ), + _buildLoadedTab(loadedGroups), + ], + ), + ), + ); + }, + ); + }, + ); + } + + Widget _tabBadge(BuildContext context, String label, {Color? color}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: (color ?? Theme.of(context).colorScheme.onSecondary) + .withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: color ?? Theme.of(context).colorScheme.onSecondary, + ), + ), + ); + } +} diff --git a/lib/feature/scan/presentation/scan_screen.dart b/lib/feature/scan/presentation/scan_screen.dart deleted file mode 100644 index e2e80d9..0000000 --- a/lib/feature/scan/presentation/scan_screen.dart +++ /dev/null @@ -1,472 +0,0 @@ -import 'dart:async'; -import 'package:collection/collection.dart'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_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/scan/presentation/scanner.dart'; -import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart'; -import 'package:hl_lieferservice/feature/settings/bloc/settings_state.dart'; -import 'package:hl_lieferservice/model/article.dart'; -import 'package:hl_lieferservice/model/car.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; -import 'package:hl_lieferservice/model/tour.dart'; -import 'package:hl_lieferservice/widget/home/bloc/navigation_event.dart'; -import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; -import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; - -import '../../../widget/home/bloc/navigation_bloc.dart'; -import '../../delivery/bloc/tour_bloc.dart'; - -class ArticleScanningScreen extends StatefulWidget { - const ArticleScanningScreen({super.key}); - - @override - State createState() => _ArticleScanningScreenState(); -} - -class _ArticleScanningScreenState extends State { - final FocusNode _focusNode = FocusNode(); - String _buffer = ''; - Timer? _bufferTimer; - int _selectedDelivery = 0; - int? _selectedCarId; - - @override - void initState() { - super.initState(); - // Focus anfordern, um Keyboard-Events zu empfangen - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); - - final state = context.read().state; - - if (state is TourLoaded) { - setState(() { - _selectedCarId = state.tour.deliveries[_selectedDelivery].carId; - }); - } - } - - @override - void dispose() { - _focusNode.dispose(); - _bufferTimer?.cancel(); - super.dispose(); - } - - void _handleKey(KeyEvent event) { - if (event is KeyDownEvent) { - if (event.logicalKey == LogicalKeyboardKey.enter) { - // Enter = Scan abgeschlossen - _bufferTimer?.cancel(); - if (_buffer.isNotEmpty) { - _handleBarcodeScanned(_buffer); - _buffer = ''; - } - } else { - // Zeichen zum Buffer hinzufügen - final character = event.character; - if (character != null && character.isNotEmpty) { - _buffer += character; - - // Timer zurücksetzen - _bufferTimer?.cancel(); - _bufferTimer = Timer(Duration(milliseconds: 1000), () { - // Nach 1 Sekunde ohne neue Eingabe: Buffer verarbeiten - if (_buffer.isNotEmpty) { - _handleBarcodeScanned(_buffer); - _buffer = ''; - } - }); - } - } - } - } - - void _handleBarcodeScanned(String barcode) { - if (_selectedCarId == null) { - context.read().add( - FailOperation(message: "Wählen Sie zu erst ein Fahrzeug aus"), - ); - return; - } - - final state = context.read().state as TourLoaded; - - context.read().add( - ScanArticleEvent( - articleNumber: barcode, - carId: _selectedCarId!.toString(), - deliveryId: state.tour.deliveries[_selectedDelivery].id, - ), - ); - } - - Widget _carSelection(List cars, List deliveries) { - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Fahrzeug auswählen", - style: Theme.of(context).textTheme.headlineSmall, - ), - Padding( - padding: const EdgeInsets.only(top: 10), - child: SizedBox( - width: double.infinity, - height: 50, - child: ListView( - scrollDirection: Axis.horizontal, - children: - cars.map((car) { - Color? backgroundColor; - Color? iconColor = Theme.of(context).primaryColor; - Color? textColor; - - if (_selectedCarId == car.id) { - backgroundColor = Theme.of(context).primaryColor; - textColor = Theme.of(context).colorScheme.onSecondary; - iconColor = Theme.of(context).colorScheme.onSecondary; - } - - return Padding( - padding: const EdgeInsets.only(right: 8), - child: GestureDetector( - onTap: () { - context.read().add( - AssignCarEvent( - deliveryId: deliveries[_selectedDelivery].id, - carId: car.id.toString(), - ), - ); - - setState(() { - _selectedCarId = car.id; - }); - }, - child: Chip( - backgroundColor: backgroundColor, - label: Row( - children: [ - Icon( - Icons.local_shipping, - color: iconColor, - size: 20, - ), - Padding( - padding: const EdgeInsets.only(left: 5), - child: Text( - car.plate, - style: TextStyle( - color: textColor, - fontSize: 12, - ), - ), - ), - ], - ), - ), - ), - ); - }).toList(), - ), - ), - ), - ], - ), - ); - } - - Widget _articles(List
articles) { - List
scannableArticles = - articles.where((article) => article.scannable).toList(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 20, bottom: 20), - child: Text( - "Artikel", - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - scannableArticles.isEmpty - ? Center( - child: Text( - 'Keine Artikel zum Scannen vorhanden', - style: TextStyle(fontSize: 18), - ), - ) - : ListView.separated( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - itemCount: scannableArticles.length, - separatorBuilder: - (context, index) => Divider( - height: 0, - color: - Theme.of(context).colorScheme.surfaceContainerHighest, - ), - itemBuilder: (context, index) { - final article = scannableArticles[index]; - - return ListTile( - leading: - article.scannedAmount == article.amount - ? Icon( - Icons.check_circle, - color: Colors.green, - size: 32, - ) - : Container( - width: 32, - alignment: Alignment.center, - child: Text( - '${article.scannedAmount}/${article.amount}', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: - article.scannedAmount > 0 - ? Colors.blue - : Colors.grey, - ), - ), - ), - title: Text( - article.name, - style: TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text("Artikelnr. ${article.articleNumber}"), - tileColor: - article.scannedAmount == article.amount - ? Colors.green.withValues(alpha: 0.1) - : Theme.of(context).colorScheme.onSecondary, - ); - }, - ), - ], - ); - } - - void _selectDelivery(int? index) { - setState(() { - _selectedDelivery = index!; - }); - } - - Widget _navigation(List deliveries) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton( - onPressed: - _selectedDelivery > 0 - ? () => { - if (_selectedDelivery > 0) - { - setState(() { - _selectedDelivery -= 1; - _selectedCarId = deliveries[_selectedDelivery].carId; - }), - }, - } - : null, - child: Text("zurück"), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 20, right: 20), - child: DropdownButton( - menuWidth: MediaQuery.of(context).size.width, - isExpanded: true, - items: - deliveries - .where( - (delivery) => delivery.state != DeliveryState.finished, - ) - .mapIndexed( - (index, delivery) => DropdownMenuItem( - value: index, - child: Text( - delivery.customer.name, - overflow: TextOverflow.ellipsis, - ), - ), - ) - .toList(), - onChanged: _selectDelivery, - value: _selectedDelivery, - ), - ), - ), - OutlinedButton( - onPressed: - _selectedDelivery < deliveries.length - 1 - ? () => { - if (_selectedDelivery + 1 < deliveries.length) - { - setState(() { - _selectedDelivery += 1; - _selectedCarId = deliveries[_selectedDelivery].carId; - }), - }, - } - : null, - child: Text("weiter"), - ), - ], - ); - } - - Widget _deliveryStepper(Tour tour) { - final settingsState = context.read().state; - Widget scannerWidget = BarcodeScannerWidget( - onBarcodeDetected: _handleBarcodeScanned, - ); - - if (settingsState is AppSettingsFailed) { - context.read().add( - FailOperation( - message: - "Einstellungen konnten nicht geladen werden. Nutze Kamera-Scanner.", - ), - ); - } - - if (settingsState is AppSettingsLoaded) { - if (settingsState.settings.useHardwareScanner) { - scannerWidget = Container(); - } - } - - // Also count aborted or hold deliveries as "delivered" - final allDeliveredOrAllScanned = tour.deliveries - .where((delivery) => delivery.state != DeliveryState.finished) - .every((delivery) => delivery.allArticlesScanned()); - - if (allDeliveredOrAllScanned) { - return Padding( - padding: const EdgeInsets.all(25), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 25), - child: Icon( - Icons.check_circle_outline, - size: 72, - color: Theme.of(context).colorScheme.primary, - ), - ), - Text("Alles erledigt - es gibt nichts mehr zu scannen!"), - Padding( - padding: const EdgeInsets.only(top: 25), - child: FilledButton( - onPressed: () { - Navigator.of(context).pop(); - context.read().add( - NavigateToIndex(index: 1), - ); - }, - child: Text("Tour starten"), - ), - ), - ], - ), - ), - ); - } - - return Padding( - padding: const EdgeInsets.all(0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - scannerWidget, - _carSelection(tour.driver.cars, tour.deliveries), - _articles(tour.deliveries[_selectedDelivery].articles), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (state is TourLoaded) { - Delivery delivery = state.tour.deliveries[_selectedDelivery]; - - // Also count aborted or hold deliveries as "delivered" - final allDeliveredOrAllScanned = state.tour.deliveries - .where((delivery) => delivery.state != DeliveryState.finished) - .every((delivery) => delivery.allArticlesScanned()); - - return Scaffold( - appBar: AppBar( - title: - allDeliveredOrAllScanned - ? Text( - "Artikel scannen", - style: TextStyle( - color: Theme.of(context).colorScheme.onSecondary, - ), - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - delivery.customer.name, - style: TextStyle( - color: Theme.of(context).colorScheme.onSecondary, - fontWeight: FontWeight.w500, - ), - ), - Text( - delivery.customer.address.toString(), - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.normal, - color: Theme.of(context).colorScheme.onSecondary, - ), - ), - ], - ), - backgroundColor: Theme.of(context).primaryColor, - ), - bottomNavigationBar: - allDeliveredOrAllScanned - ? Text("") - : Padding( - padding: const EdgeInsets.all(25), - child: _navigation( - state.tour.deliveries - .where( - (delivery) => - delivery.state == DeliveryState.ongoing, - ) - .toList(), - ), - ), - body: KeyboardListener( - focusNode: _focusNode, - onKeyEvent: _handleKey, - child: _deliveryStepper(state.tour), - ), - ); - } - - return Container(); - }, - ); - } -} diff --git a/lib/feature/scan/presentation/scanner.dart b/lib/feature/scan/presentation/scanner.dart index 6b9b22c..a0badbc 100644 --- a/lib/feature/scan/presentation/scanner.dart +++ b/lib/feature/scan/presentation/scanner.dart @@ -53,11 +53,8 @@ class _BarcodeScannerWidgetState extends State { @override Widget build(BuildContext context) { - final screenHeight = MediaQuery.of(context).size.height; - final scannerHeight = screenHeight / 4; - return Container( - height: scannerHeight, + height: 150, decoration: BoxDecoration( border: Border.all( color: _isDetected ? Colors.green : Colors.grey, diff --git a/lib/feature/settings/presentation/settings_page.dart b/lib/feature/settings/presentation/settings_page.dart index 89d6cb7..13491b5 100644 --- a/lib/feature/settings/presentation/settings_page.dart +++ b/lib/feature/settings/presentation/settings_page.dart @@ -122,10 +122,9 @@ class _SettingsPage extends State { ], ), appBar: AppBar( - title: Text( - "Einstellungen", - style: Theme.of(context).textTheme.headlineMedium, - ), + title: const Text("Einstellungen"), + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Theme.of(context).colorScheme.onSecondary, ), ); } diff --git a/lib/model/customer.dart b/lib/model/customer.dart index 3eec4da..a980a69 100644 --- a/lib/model/customer.dart +++ b/lib/model/customer.dart @@ -3,12 +3,17 @@ import 'package:hl_lieferservice/dto/customer.dart'; import 'address.dart'; class Customer { - const Customer({required this.name, required this.address}); + const Customer({required this.name, required this.address, this.email}); final String name; final Address address; + final String? email; factory Customer.fromDTO(CustomerDTO dto) { - return Customer(name: dto.name, address: Address.fromDTO(dto.address)); + return Customer( + name: dto.name, + address: Address.fromDTO(dto.address), + email: dto.eMail, + ); } } diff --git a/lib/model/tour.dart b/lib/model/tour.dart index 769f305..8ccc443 100644 --- a/lib/model/tour.dart +++ b/lib/model/tour.dart @@ -60,6 +60,23 @@ class Tour { .length; } + /// Returns true if the car still has loaded articles assigned to a delivery + /// that has not been finished yet. Scannable articles count when their + /// effective scanned amount (scanned minus removed) is positive; non-scannable + /// articles count when their target amount is greater than zero. + bool hasUndeliveredLoadedArticles(int carId) { + return deliveries.any((delivery) { + if (delivery.carId != carId) return false; + if (delivery.state == DeliveryState.finished) return false; + return delivery.articles.any((article) { + if (article.scannable) { + return article.scannedAmount > article.scannedRemovedAmount; + } + return article.amount > 0; + }); + }); + } + Tour copyWith({ DateTime? date, String? discountArticleNumber, diff --git a/lib/widget/app.dart b/lib/widget/app.dart index a294899..03927f7 100644 --- a/lib/widget/app.dart +++ b/lib/widget/app.dart @@ -4,7 +4,13 @@ import 'package:hl_lieferservice/bloc/app_bloc.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; import 'package:hl_lieferservice/feature/authentication/presentation/login_enforcer.dart'; import 'package:hl_lieferservice/feature/authentication/service/userinfo.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; +import 'package:hl_lieferservice/feature/car_selection/presentation/car_selection_enforcer.dart'; +import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart'; +import 'package:hl_lieferservice/feature/cars/repository/cars_repository.dart'; +import 'package:hl_lieferservice/feature/cars/service/cars_service.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/repository/tour_repository.dart'; import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart'; @@ -46,11 +52,23 @@ class _DeliveryAppState extends State { create: (context) => TourBloc( opBloc: context.read(), + authBloc: context.read(), tourRepository: TourRepository( service: TourService(), ), ), ), + BlocProvider( + create: (context) => + CarSelectBloc(repository: CarSelectionRepository()), + ), + BlocProvider( + create: (context) => CarsBloc( + repository: CarsRepository(service: CarService()), + opBloc: context.read(), + authBloc: context.read(), + ), + ), ], child: MaterialApp( home: OperationViewEnforcer( @@ -67,7 +85,9 @@ class _DeliveryAppState extends State { } if (state is AppConfigLoaded) { - return LoginEnforcer(child: Home()); + return LoginEnforcer( + child: CarSelectionEnforcer(child: Home()), + ); } return Container(); diff --git a/lib/widget/home/presentation/home.dart b/lib/widget/home/presentation/home.dart index 0901edc..0c3393b 100644 --- a/lib/widget/home/presentation/home.dart +++ b/lib/widget/home/presentation/home.dart @@ -7,15 +7,12 @@ 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/overview/presentation/delivery_overview_page.dart'; import 'package:hl_lieferservice/feature/scan/presentation/scan_page.dart'; -import 'package:hl_lieferservice/widget/app_bar.dart'; +import 'package:hl_lieferservice/feature/car_selection/presentation/selected_car_bar.dart'; +import 'package:hl_lieferservice/feature/settings/presentation/settings_page.dart'; import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart'; import 'package:hl_lieferservice/widget/home/bloc/navigation_state.dart'; import 'package:hl_lieferservice/widget/navigation_bar/presentation/navigation_bar.dart'; -import '../../../feature/cars/bloc/cars_bloc.dart'; -import '../../../feature/cars/repository/cars_repository.dart'; -import '../../../feature/cars/service/cars_service.dart'; -import '../../operations/bloc/operation_bloc.dart'; class Home extends StatefulWidget { const Home({super.key}); @@ -44,14 +41,11 @@ class _HomeState extends State { } if (index == 2) { - return BlocProvider( - create: - (context) => CarsBloc( - repository: CarsRepository(service: CarService()), - opBloc: context.read(), - ), - child: CarManagementPage(), - ); + return CarManagementPage(); + } + + if (index == 3) { + return SettingsPage(); } return Container(); @@ -64,12 +58,14 @@ class _HomeState extends State { final currentState = state as NavigationInfo; return Scaffold( - appBar: PreferredSize( - preferredSize: Size.fromHeight(kToolbarHeight), - child: CustomAppBar(), - ), body: _buildPage(currentState.navigationIndex), - bottomNavigationBar: AppNavigationBar(), + bottomNavigationBar: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SelectedCarBar(), + AppNavigationBar(), + ], + ), ); }, ); diff --git a/lib/widget/navigation_bar/presentation/navigation_bar.dart b/lib/widget/navigation_bar/presentation/navigation_bar.dart index 7f313cd..b22905d 100644 --- a/lib/widget/navigation_bar/presentation/navigation_bar.dart +++ b/lib/widget/navigation_bar/presentation/navigation_bar.dart @@ -32,6 +32,11 @@ class _AppNavigationBarState extends State { icon: Icon(Icons.local_shipping), label: "Fahrzeuge", ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: "Einstellungen", + ), ], onDestinationSelected: (int index) { context.read().add(NavigateToIndex(index: index)); diff --git a/lib/widget/operations/bloc/operation_bloc.dart b/lib/widget/operations/bloc/operation_bloc.dart index 048f873..afaf33a 100644 --- a/lib/widget/operations/bloc/operation_bloc.dart +++ b/lib/widget/operations/bloc/operation_bloc.dart @@ -4,28 +4,19 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_state.dart'; class OperationBloc extends Bloc { OperationBloc() : super(OperationIdle()) { - on(_loadOperation); on(_failOperation); on(_finishOperation); } - Future _loadOperation(LoadOperation event, Emitter emit) async { - emit(OperationLoading()); - } - Future _failOperation(FailOperation event, Emitter emit) async { emit(OperationFailed(message: event.message)); - - await Future.delayed(Duration(seconds: 5)); - + await Future.delayed(const Duration(seconds: 5)); emit(OperationIdle()); } Future _finishOperation(FinishOperation event, Emitter emit) async { emit(OperationFinished(message: event.message)); - - await Future.delayed(Duration(seconds: 5)); - + await Future.delayed(const Duration(seconds: 5)); emit(OperationIdle()); } -} \ No newline at end of file +} diff --git a/lib/widget/operations/bloc/operation_event.dart b/lib/widget/operations/bloc/operation_event.dart index 95dc3e8..9d77652 100644 --- a/lib/widget/operations/bloc/operation_event.dart +++ b/lib/widget/operations/bloc/operation_event.dart @@ -1,7 +1,5 @@ abstract class OperationEvent {} -class LoadOperation extends OperationEvent {} - class FailOperation extends OperationEvent { String message; @@ -12,4 +10,4 @@ class FinishOperation extends OperationEvent { String? message; FinishOperation({this.message}); -} \ No newline at end of file +} diff --git a/lib/widget/operations/bloc/operation_state.dart b/lib/widget/operations/bloc/operation_state.dart index aa179f3..201fc3c 100644 --- a/lib/widget/operations/bloc/operation_state.dart +++ b/lib/widget/operations/bloc/operation_state.dart @@ -2,8 +2,6 @@ abstract class OperationState {} class OperationIdle extends OperationState {} -class OperationLoading extends OperationState {} - class OperationFailed extends OperationState { String message; @@ -14,4 +12,4 @@ class OperationFinished extends OperationState { String? message; OperationFinished({this.message}); -} \ No newline at end of file +} diff --git a/lib/widget/operations/presentation/operation_view_enforcer.dart b/lib/widget/operations/presentation/operation_view_enforcer.dart index f99339d..ece8e27 100644 --- a/lib/widget/operations/presentation/operation_view_enforcer.dart +++ b/lib/widget/operations/presentation/operation_view_enforcer.dart @@ -4,47 +4,21 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; import '../bloc/operation_state.dart'; -/// OperationViewEnforcer -/// -/// A view that encapsulates the functionality to react to asynchronous operations. -/// It is capable of showing a loading indicator while an operation is ongoing and it shows -/// a error message if the operation failed. -class OperationViewEnforcer extends StatefulWidget { +/// Listens to [OperationBloc] and shows SnackBars for success and error +/// messages. Loading indicators are handled locally by each feature. +class OperationViewEnforcer extends StatelessWidget { final Widget child; + const OperationViewEnforcer({super.key, required this.child}); - @override - State createState() => _OperationViewEnforcerState(); -} - -class _OperationViewEnforcerState extends State { - OverlayEntry? _overlayEntry; - - @override - void dispose() { - _overlayEntry?.remove(); - super.dispose(); - } @override Widget build(BuildContext context) { return BlocListener( listener: (context, state) { - if (state is OperationLoading) { - if (_overlayEntry == null) { - _overlayEntry = _createOverlayEntry(context); - Overlay.of(context).insert(_overlayEntry!); - } - } else { - _overlayEntry?.remove(); - _overlayEntry = null; - } - - if (state is OperationFinished) { - if (state.message != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.message!)), - ); - } + if (state is OperationFinished && state.message != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.message!)), + ); } if (state is OperationFailed) { @@ -53,20 +27,7 @@ class _OperationViewEnforcerState extends State { ); } }, - child: widget.child, + child: child, ); } - - OverlayEntry _createOverlayEntry(BuildContext context) { - return OverlayEntry( - builder: (context) => DecoratedBox( - decoration: const BoxDecoration( - color: Color.fromRGBO(128, 128, 128, 0.8), - ), - child: const Center( - child: CircularProgressIndicator(color: Colors.white), - ), - ), - ); - } -} \ No newline at end of file +} diff --git a/pubspec.lock b/pubspec.lock index 1f1f501..c6ee03e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -165,10 +165,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -668,26 +668,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1033,10 +1033,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" timing: dependency: transitive description: