BIG FAT
This commit is contained in:
31
CLAUDE.md
31
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.
|
||||
85
docs/finish_delivery.md
Normal file
85
docs/finish_delivery.md
Normal file
@ -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 = <Zeitstempel>`, räumt entfernte Artikel auf. Body `{ "delivery_id": <id>, "delivered_at": "<yyyy-MM-ddTHH:mm:ss>" }` |
|
||||
|
||||
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.
|
||||
@ -20,7 +20,5 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,19 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Kamera-Berechtigung -->
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Wir benötigen Zugriff auf deine Kamera zum Scannen von Barcodes.</string>
|
||||
|
||||
<!-- Weitere iOS-Einstellungen -->
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Diese App benötigt keinen Standortzugriff.</string>
|
||||
<!-- GPS Permissions -->
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Diese App benötigt deinen Standort, um die Lieferdistanz zu berechnen.</string>
|
||||
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Diese App benötigt deinen Standort, um die Lieferdistanz zu berechnen.</string>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@ -33,10 +22,50 @@
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>myapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Wir benötigen Zugriff auf deine Kamera zum Scannen von Barcodes.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Diese App benötigt deinen Standort, um die Lieferdistanz zu berechnen.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Diese App benötigt deinen Standort, um die Lieferdistanz zu berechnen.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>FlutterSceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@ -54,21 +83,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>myapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -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<String, dynamic> json) => _$CustomerDTOFromJson(json);
|
||||
Map<dynamic, dynamic> toJson() => _$CustomerDTOToJson(this);
|
||||
|
||||
@ -9,7 +9,12 @@ part of 'customer.dart';
|
||||
CustomerDTO _$CustomerDTOFromJson(Map<String, dynamic> json) => CustomerDTO(
|
||||
name: json['name'] as String,
|
||||
address: AddressDTO.fromJson(json['address'] as Map<String, dynamic>),
|
||||
eMail: json['e_mail'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CustomerDTOToJson(CustomerDTO instance) =>
|
||||
<String, dynamic>{'name': instance.name, 'address': instance.address};
|
||||
<String, dynamic>{
|
||||
'name': instance.name,
|
||||
'address': instance.address,
|
||||
'e_mail': instance.eMail,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -15,15 +15,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
: super(Unauthenticated()) {
|
||||
on<SetAuthenticatedEvent>(_auth);
|
||||
on<Logout>(_logout);
|
||||
on<SessionExpiredEvent>(_sessionExpired);
|
||||
}
|
||||
|
||||
Future<void> _auth(
|
||||
SetAuthenticatedEvent event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
operationBloc.add(LoadOperation());
|
||||
await Future.delayed(Duration(seconds: 5));
|
||||
|
||||
try {
|
||||
debugPrint("Retrieve user information");
|
||||
|
||||
@ -31,7 +29,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
var state = Authenticated(sessionId: event.sessionId, user: response);
|
||||
locator.registerSingleton<Authenticated>(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<AuthEvent, AuthState> {
|
||||
}
|
||||
|
||||
Future<void> _logout(Logout event, Emitter<AuthState> emit) async {
|
||||
if (locator.isRegistered<Authenticated>()) {
|
||||
locator.unregister<Authenticated>();
|
||||
}
|
||||
emit(Unauthenticated());
|
||||
}
|
||||
|
||||
Future<void> _sessionExpired(
|
||||
SessionExpiredEvent event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
if (locator.isRegistered<Authenticated>()) {
|
||||
locator.unregister<Authenticated>();
|
||||
}
|
||||
emit(Unauthenticated(sessionExpired: true));
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,3 +11,5 @@ class Logout extends AuthEvent {
|
||||
|
||||
Logout({required this.username});
|
||||
}
|
||||
|
||||
class SessionExpiredEvent extends AuthEvent {}
|
||||
@ -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;
|
||||
|
||||
@ -18,7 +18,8 @@ class LoginEnforcer extends StatelessWidget {
|
||||
return child;
|
||||
}
|
||||
|
||||
return LoginPage();
|
||||
final expired = state is Unauthenticated && state.sessionExpired;
|
||||
return LoginPage(sessionExpired: expired);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<StatefulWidget> createState() => _LoginPageState();
|
||||
@ -60,7 +62,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
|
||||
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,7 +129,21 @@ class _LoginPageState extends State<LoginPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: Center(
|
||||
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: [
|
||||
@ -180,6 +196,9 @@ class _LoginPageState extends State<LoginPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<CarSelectEvent, CarSelectState> {
|
||||
final CarSelectionRepository repository;
|
||||
|
||||
CarSelectBloc({required this.repository}) : super(CarSelectInitial()) {
|
||||
on<CarSelectLoad>(_load);
|
||||
on<CarSelectConfirm>(_confirm);
|
||||
on<CarSelectChange>(_change);
|
||||
on<CarSelectCancel>(_cancel);
|
||||
}
|
||||
|
||||
Future<void> _load(
|
||||
CarSelectLoad event,
|
||||
Emitter<CarSelectState> 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<CarSelectState> emit) {
|
||||
final previousCar =
|
||||
state is CarSelectComplete ? (state as CarSelectComplete).selectedCar : null;
|
||||
emit(CarSelectRequired(previousCar: previousCar));
|
||||
}
|
||||
|
||||
void _cancel(CarSelectCancel event, Emitter<CarSelectState> emit) {
|
||||
// Restore without touching SharedPreferences — no tour reload needed.
|
||||
emit(CarSelectComplete(selectedCar: event.car));
|
||||
}
|
||||
|
||||
Future<void> _confirm(
|
||||
CarSelectConfirm event,
|
||||
Emitter<CarSelectState> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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});
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<CarSelectionEnforcer> createState() => _CarSelectionEnforcerState();
|
||||
}
|
||||
|
||||
class _CarSelectionEnforcerState extends State<CarSelectionEnforcer> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<CarSelectBloc>().add(CarSelectLoad());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CarSelectBloc, CarSelectState>(
|
||||
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<CarSelectBloc>().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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
232
lib/feature/car_selection/presentation/car_selection_page.dart
Normal file
232
lib/feature/car_selection/presentation/car_selection_page.dart
Normal file
@ -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<CarSelectionPage> createState() => _CarSelectionPageState();
|
||||
}
|
||||
|
||||
class _CarSelectionPageState extends State<CarSelectionPage> {
|
||||
Car? _selectedCar;
|
||||
|
||||
bool get _isChanging => widget.previousCar != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedCar = widget.previousCar;
|
||||
final authState = context.read<AuthBloc>().state as Authenticated;
|
||||
context.read<CarsBloc>().add(CarLoad(teamId: authState.user.number));
|
||||
}
|
||||
|
||||
void _onAddCar() {
|
||||
final authState = context.read<AuthBloc>().state as Authenticated;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => CarDialog(
|
||||
onAction: (plate) {
|
||||
context.read<CarsBloc>().add(
|
||||
CarAdd(teamId: authState.user.number, plate: plate),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onConfirm() {
|
||||
if (_selectedCar == null) return;
|
||||
context.read<CarSelectBloc>().add(CarSelectConfirm(car: _selectedCar!));
|
||||
}
|
||||
|
||||
Widget _buildCarList(List<Car> 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<AuthBloc>().state as Authenticated;
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<CarsBloc>().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<CarSelectBloc, CarSelectState>(
|
||||
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<CarSelectBloc>().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<CarsBloc, CarsState>(
|
||||
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<AuthBloc>().state
|
||||
as Authenticated;
|
||||
context.read<CarsBloc>().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"),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lib/feature/car_selection/presentation/selected_car_bar.dart
Normal file
56
lib/feature/car_selection/presentation/selected_car_bar.dart
Normal file
@ -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<CarSelectBloc, CarSelectState>(
|
||||
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<CarSelectBloc>().add(CarSelectChange()),
|
||||
icon: const Icon(Icons.swap_horiz, size: 18),
|
||||
label: const Text("Wechseln"),
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<CarSelection?> 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<void> 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!);
|
||||
}
|
||||
}
|
||||
@ -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<CarEvents, CarsState> {
|
||||
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>(_carAdd);
|
||||
on<CarEdit>(_carEdit);
|
||||
@ -19,12 +23,27 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
|
||||
on<CarLoad>(_carLoad);
|
||||
}
|
||||
|
||||
void _handleError(Object e, String fallbackMessage) {
|
||||
if (e is UserUnauthorized) {
|
||||
authBloc.add(SessionExpiredEvent());
|
||||
} else {
|
||||
opBloc.add(FailOperation(message: fallbackMessage));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _carLoad(CarLoad event, Emitter<CarsState> 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<Car> 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<CarEvents, CarsState> {
|
||||
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<CarEvents, CarsState> {
|
||||
|
||||
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<CarEvents, CarsState> {
|
||||
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<CarEvents, CarsState> {
|
||||
|
||||
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<CarEvents, CarsState> {
|
||||
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<CarEvents, CarsState> {
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,12 +40,29 @@ 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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -6,9 +6,11 @@ import 'package:flutter/material.dart';
|
||||
|
||||
class CarManagementOverview extends StatefulWidget {
|
||||
final List<Car> cars;
|
||||
final int? selectedCarId;
|
||||
final Function(String plate) onAdd;
|
||||
final Function(String id) onDelete;
|
||||
final Function(String id, String plate) onEdit;
|
||||
final Future<void> 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<CarManagementOverview> {
|
||||
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<CarManagementOverview> {
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<CarManagementPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
context.read<CarsBloc>().add(CarLoad(teamId: _authState.user.number, force: true));
|
||||
}
|
||||
|
||||
void _remove(String id) {
|
||||
final carId = int.parse(id);
|
||||
|
||||
final carSelectState = context.read<CarSelectBloc>().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<TourBloc>().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<CarsBloc>().add(
|
||||
CarDelete(carId: id, teamId: _authState.user.number),
|
||||
);
|
||||
@ -68,11 +118,20 @@ class _CarManagementPageState extends State<CarManagementPage> {
|
||||
}
|
||||
|
||||
if (state is CarsLoaded) {
|
||||
return BlocBuilder<CarSelectBloc, CarSelectState>(
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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<TourEvent, TourState> {
|
||||
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<TourEvent, TourState> {
|
||||
@override
|
||||
Future<void> 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<TourState> 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<TourEvent, TourState> {
|
||||
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<TourEvent, TourState> {
|
||||
Emitter<TourState> emit,
|
||||
) async {
|
||||
Map<String, double> distances = {};
|
||||
opBloc.add(LoadOperation());
|
||||
|
||||
emit(TourRequestingDistances(tour: event.tour, payments: event.payments));
|
||||
|
||||
@ -135,7 +138,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
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<TourEvent, TourState> {
|
||||
}
|
||||
}
|
||||
|
||||
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<TourEvent, TourState> {
|
||||
) 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<TourEvent, TourState> {
|
||||
void _holdDelivery(HoldDeliveryEvent event, Emitter<TourState> 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<TourEvent, TourState> {
|
||||
) 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<TourState> emit) async {
|
||||
final currentState = state;
|
||||
opBloc.add(LoadOperation());
|
||||
|
||||
if (currentState is TourLoaded) {
|
||||
try {
|
||||
@ -333,9 +316,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
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<TourEvent, TourState> {
|
||||
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<TourEvent, TourState> {
|
||||
Future<void> _assignCar(AssignCarEvent event, Emitter<TourState> 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<void> _load(LoadTour event, Emitter<TourState> 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<TourEvent, TourState> {
|
||||
Emitter<TourState> emit,
|
||||
) async {
|
||||
final currentState = state;
|
||||
opBloc.add(LoadOperation());
|
||||
|
||||
if (currentState is TourLoaded) {
|
||||
try {
|
||||
@ -415,11 +387,9 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
);
|
||||
|
||||
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<TourEvent, TourState> {
|
||||
Emitter<TourState> 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<TourEvent, TourState> {
|
||||
Emitter<TourState> 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<TourEvent, TourState> {
|
||||
UpdateDiscountEvent event,
|
||||
Emitter<TourState> 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<TourEvent, TourState> {
|
||||
RemoveDiscountEvent event,
|
||||
Emitter<TourState> 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<TourState> 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<TourState> emit) async {
|
||||
opBloc.add(LoadOperation());
|
||||
|
||||
try {
|
||||
await tourRepository.unscan(
|
||||
event.deliveryId,
|
||||
@ -543,29 +471,18 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
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<TourState> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<NoteEvent, NoteState> {
|
||||
final NoteRepository repository;
|
||||
final OperationBloc opBloc;
|
||||
final AuthBloc authBloc;
|
||||
final String deliveryId;
|
||||
|
||||
StreamSubscription? _combinedSubscription;
|
||||
@ -22,6 +26,7 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
|
||||
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<NoteEvent, NoteState> {
|
||||
@override
|
||||
Future<void> 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<void> _dataUpdated(DataUpdated event, Emitter<NoteState> emit) async {
|
||||
emit(
|
||||
NoteLoaded(
|
||||
@ -82,32 +94,21 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
|
||||
RemoveImageNote event,
|
||||
Emitter<NoteState> 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<void> _upload(AddImageNote event, Emitter<NoteState> 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<NoteEvent, NoteState> {
|
||||
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<void> _add(AddNote event, Emitter<NoteState> 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<void> _edit(EditNote event, Emitter<NoteState> 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<void> _remove(RemoveNote event, Emitter<NoteState> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<DeliveryStepInfo> {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Widget _deliveryStatusChangeActions() {
|
||||
List<Widget> actions = [];
|
||||
Widget _statusOverflow() {
|
||||
final state = widget.delivery.state;
|
||||
final List<PopupMenuEntry<_StatusAction>> entries;
|
||||
|
||||
if (widget.delivery.state == DeliveryState.ongoing) {
|
||||
actions = [
|
||||
Column(
|
||||
if (state == DeliveryState.ongoing) {
|
||||
entries = const [
|
||||
PopupMenuItem(
|
||||
value: _StatusAction.hold,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
Icon(Icons.change_circle, color: Colors.orangeAccent),
|
||||
SizedBox(width: 12),
|
||||
Text("Zurückstellen"),
|
||||
],
|
||||
),
|
||||
),
|
||||
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"),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return PopupMenuButton<_StatusAction>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
tooltip: "Status ändern",
|
||||
itemBuilder: (context) => entries,
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _StatusAction.hold:
|
||||
context.read<TourBloc>().add(
|
||||
HoldDeliveryEvent(deliveryId: widget.delivery.id),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.change_circle,
|
||||
color: Colors.orangeAccent,
|
||||
size: 42,
|
||||
),
|
||||
),
|
||||
Text("Zurückstellen"),
|
||||
],
|
||||
),
|
||||
|
||||
Column(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
break;
|
||||
case _StatusAction.cancel:
|
||||
context.read<TourBloc>().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"),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (widget.delivery.state == DeliveryState.canceled ||
|
||||
widget.delivery.state == DeliveryState.onhold ||
|
||||
widget.delivery.state == DeliveryState.finished) {
|
||||
actions = [
|
||||
Column(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
break;
|
||||
case _StatusAction.reactivate:
|
||||
context.read<TourBloc>().add(
|
||||
ReactivateDeliveryEvent(deliveryId: widget.delivery.id),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.published_with_changes,
|
||||
color: Colors.blueAccent,
|
||||
size: 42
|
||||
),
|
||||
),
|
||||
Text("Reaktivieren"),
|
||||
],
|
||||
),
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: actions,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -119,55 +118,46 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
|
||||
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(
|
||||
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:
|
||||
widget.delivery.contactPerson?.phoneNumber != null
|
||||
onPressed: hasPhone
|
||||
? () async {
|
||||
await launchUrl(
|
||||
Uri(
|
||||
scheme: "tel",
|
||||
path:
|
||||
widget
|
||||
.delivery
|
||||
.contactPerson
|
||||
?.phoneNumber!,
|
||||
),
|
||||
Uri(scheme: "tel", path: phone),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
icon: Icon(Icons.phone),
|
||||
icon: const Icon(Icons.phone),
|
||||
),
|
||||
Text("Anrufen"),
|
||||
const Text("Anrufen"),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
Column(
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton.filled(
|
||||
onPressed: () {
|
||||
_launchMapsUrl("google");
|
||||
},
|
||||
icon: Icon(Icons.map_outlined),
|
||||
onPressed: () => _launchMapsUrl("google"),
|
||||
icon: const Icon(Icons.map_outlined),
|
||||
),
|
||||
Text("Google Maps"),
|
||||
const Text("Google Maps"),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 10, bottom: 10),
|
||||
child: Divider(),
|
||||
),
|
||||
|
||||
_deliveryStatusChangeActions(),
|
||||
_statusOverflow(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -176,6 +166,16 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
|
||||
}
|
||||
|
||||
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(
|
||||
@ -227,10 +227,25 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
|
||||
children: [
|
||||
Icon(Icons.phone, color: Theme.of(context).primaryColor),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 10),
|
||||
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(
|
||||
widget.delivery.contactPerson?.phoneNumber.toString() ??
|
||||
"",
|
||||
emailText,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -275,30 +290,68 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
|
||||
);
|
||||
}
|
||||
|
||||
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: [
|
||||
if (hasDesiredTime) ...[
|
||||
Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.all(15),
|
||||
child: Icon(
|
||||
Icons.warning,
|
||||
color: Theme.of(context).primaryColor,
|
||||
size: 28,
|
||||
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)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -330,7 +383,7 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: _deliveryAgreements(),
|
||||
child: _agreementsAndDesiredTime(),
|
||||
),
|
||||
|
||||
Padding(
|
||||
|
||||
@ -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:");
|
||||
|
||||
@ -1,32 +1,30 @@
|
||||
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(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 15),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.onSecondary,
|
||||
@ -40,9 +38,9 @@ class DeliveryInfo extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.calendar_month),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 5),
|
||||
const Icon(Icons.calendar_month),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 5),
|
||||
child: Text("Datum"),
|
||||
),
|
||||
],
|
||||
@ -50,32 +48,40 @@ class DeliveryInfo extends StatelessWidget {
|
||||
Text(date),
|
||||
],
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 15),
|
||||
child: Row(
|
||||
const SizedBox(height: 15),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.local_shipping_outlined),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 5),
|
||||
const Icon(Icons.local_shipping_outlined),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 5),
|
||||
child: Text("Lieferungen"),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(amountDeliveries),
|
||||
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<Color>(
|
||||
allDone ? Colors.green : Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
builder: (context) => BlocProvider(
|
||||
create: (context) => NoteBloc(
|
||||
deliveryId: delivery.id,
|
||||
opBloc: context.read<OperationBloc>(),
|
||||
repository: NoteRepository(
|
||||
service: NoteService(),
|
||||
authBloc: context.read<AuthBloc>(),
|
||||
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),
|
||||
),
|
||||
leading: _leading(context),
|
||||
tileColor: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
subtitle: Text(delivery.customer.address.toString()),
|
||||
trailing: Icon(Icons.arrow_forward_ios),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<DeliveryList> {
|
||||
builder: (context, state) {
|
||||
final currentState = state;
|
||||
if (currentState is TourLoaded) {
|
||||
List<Delivery> deliveries =
|
||||
currentState.tour.deliveries
|
||||
.where(
|
||||
(delivery) =>
|
||||
delivery.carId == widget.selectedCarId &&
|
||||
delivery.allArticlesScanned() &&
|
||||
delivery.state != DeliveryState.finished,
|
||||
)
|
||||
.toList();
|
||||
|
||||
List<Delivery> finishedDeliveries =
|
||||
currentState.tour.deliveries
|
||||
.where(
|
||||
(delivery) =>
|
||||
delivery.state == DeliveryState.finished &&
|
||||
delivery.carId == widget.selectedCarId,
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (deliveries.isEmpty) {
|
||||
return ListView(
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Center(child: const Text("Keine Auslieferungen gefunden")),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
switch (widget.sortType) {
|
||||
case SortType.custom:
|
||||
if (widget.sortType == 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),
|
||||
);
|
||||
break;
|
||||
|
||||
case SortType.nameDesc:
|
||||
deliveries.sort(
|
||||
(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,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
//deliveries.addAll(finishedDeliveries);
|
||||
final allDeliveries = currentState.tour.deliveries
|
||||
.where((d) => d.carId == widget.selectedCarId)
|
||||
.toList();
|
||||
|
||||
return ListView.separated(
|
||||
separatorBuilder: (context, index) => const Divider(height: 0),
|
||||
if (allDeliveries.isEmpty) {
|
||||
return ListView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
Delivery delivery = deliveries[index];
|
||||
|
||||
return DeliveryListItem(
|
||||
delivery: delivery,
|
||||
distance: currentState.distances?[delivery.id] ?? 0.0,
|
||||
children: const [
|
||||
Center(child: Text("Keine Auslieferungen gefunden")),
|
||||
],
|
||||
);
|
||||
},
|
||||
itemCount: deliveries.length,
|
||||
}
|
||||
|
||||
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.nameAsc:
|
||||
comparator = (a, b) => a.customer.name.compareTo(b.customer.name);
|
||||
break;
|
||||
case SortType.nameDesc:
|
||||
comparator = (a, b) => b.customer.name.compareTo(a.customer.name);
|
||||
break;
|
||||
case SortType.distance:
|
||||
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);
|
||||
}
|
||||
|
||||
ongoing.sort(comparator);
|
||||
nonOngoing.sort(comparator);
|
||||
|
||||
final sorted = [...ongoing, ...nonOngoing];
|
||||
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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<DeliveryOverview> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Select the first car for initialization
|
||||
// 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<CarSelectBloc>().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<DeliveryOverview> {
|
||||
context.read<TourBloc>().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<DeliveryOverview> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
return BlocListener<CarSelectBloc, CarSelectState>(
|
||||
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<DeliveryOverview> {
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 10, right: 10, bottom: 20),
|
||||
child: _carSelection(),
|
||||
),
|
||||
DeliveryList(
|
||||
selectedCarId: _selectedCarId,
|
||||
sortType: _sortType,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,16 +18,45 @@ class DeliveryOverviewPage extends StatefulWidget {
|
||||
class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<TourBloc, TourState>(
|
||||
final carState = context.watch<CarSelectBloc>().state;
|
||||
|
||||
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<TourBloc, TourState>(
|
||||
builder: (context, state) {
|
||||
if (state is TourLoaded) {
|
||||
final currentState = state;
|
||||
|
||||
return Center(
|
||||
child: DeliveryOverview(
|
||||
tour: currentState.tour,
|
||||
distances: currentState.distances ?? {},
|
||||
),
|
||||
return DeliveryOverview(
|
||||
tour: state.tour,
|
||||
distances: state.distances ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
@ -33,8 +64,9 @@ class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
|
||||
return DeliveryLoadingFailedPage();
|
||||
}
|
||||
|
||||
return Container();
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<BasicResponseDTO> 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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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<ArticleScanningScreen> createState() => _ArticleScanningScreenState();
|
||||
}
|
||||
|
||||
class _ArticleScanningScreenState extends State<ArticleScanningScreen> {
|
||||
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<TourBloc>().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<OperationBloc>().add(
|
||||
FailOperation(message: "Wählen Sie zu erst ein Fahrzeug aus"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final state = context.read<TourBloc>().state as TourLoaded;
|
||||
|
||||
context.read<TourBloc>().add(
|
||||
ScanArticleEvent(
|
||||
articleNumber: barcode,
|
||||
carId: _selectedCarId!.toString(),
|
||||
deliveryId: state.tour.deliveries[_selectedDelivery].id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _carSelection(List<Car> cars, List<Delivery> 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<TourBloc>().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<Article> articles) {
|
||||
List<Article> 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<Delivery> 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<SettingsBloc>().state;
|
||||
Widget scannerWidget = BarcodeScannerWidget(
|
||||
onBarcodeDetected: _handleBarcodeScanned,
|
||||
);
|
||||
|
||||
if (settingsState is AppSettingsFailed) {
|
||||
context.read<OperationBloc>().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<NavigationBloc>().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<TourBloc, TourState>(
|
||||
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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -53,11 +53,8 @@ class _BarcodeScannerWidgetState extends State<BarcodeScannerWidget> {
|
||||
|
||||
@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,
|
||||
|
||||
@ -122,10 +122,9 @@ class _SettingsPage extends State<SettingsPage> {
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<DeliveryApp> {
|
||||
create:
|
||||
(context) => TourBloc(
|
||||
opBloc: context.read<OperationBloc>(),
|
||||
authBloc: context.read<AuthBloc>(),
|
||||
tourRepository: TourRepository(
|
||||
service: TourService(),
|
||||
),
|
||||
),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
CarSelectBloc(repository: CarSelectionRepository()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => CarsBloc(
|
||||
repository: CarsRepository(service: CarService()),
|
||||
opBloc: context.read<OperationBloc>(),
|
||||
authBloc: context.read<AuthBloc>(),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: OperationViewEnforcer(
|
||||
@ -67,7 +85,9 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
||||
}
|
||||
|
||||
if (state is AppConfigLoaded) {
|
||||
return LoginEnforcer(child: Home());
|
||||
return LoginEnforcer(
|
||||
child: CarSelectionEnforcer(child: Home()),
|
||||
);
|
||||
}
|
||||
|
||||
return Container();
|
||||
|
||||
@ -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<Home> {
|
||||
}
|
||||
|
||||
if (index == 2) {
|
||||
return BlocProvider(
|
||||
create:
|
||||
(context) => CarsBloc(
|
||||
repository: CarsRepository(service: CarService()),
|
||||
opBloc: context.read<OperationBloc>(),
|
||||
),
|
||||
child: CarManagementPage(),
|
||||
);
|
||||
return CarManagementPage();
|
||||
}
|
||||
|
||||
if (index == 3) {
|
||||
return SettingsPage();
|
||||
}
|
||||
|
||||
return Container();
|
||||
@ -64,12 +58,14 @@ class _HomeState extends State<Home> {
|
||||
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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -32,6 +32,11 @@ class _AppNavigationBarState extends State<AppNavigationBar> {
|
||||
icon: Icon(Icons.local_shipping),
|
||||
label: "Fahrzeuge",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: "Einstellungen",
|
||||
),
|
||||
],
|
||||
onDestinationSelected: (int index) {
|
||||
context.read<NavigationBloc>().add(NavigateToIndex(index: index));
|
||||
|
||||
@ -4,28 +4,19 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_state.dart';
|
||||
|
||||
class OperationBloc extends Bloc<OperationEvent, OperationState> {
|
||||
OperationBloc() : super(OperationIdle()) {
|
||||
on<LoadOperation>(_loadOperation);
|
||||
on<FailOperation>(_failOperation);
|
||||
on<FinishOperation>(_finishOperation);
|
||||
}
|
||||
|
||||
Future<void> _loadOperation(LoadOperation event, Emitter<OperationState> emit) async {
|
||||
emit(OperationLoading());
|
||||
}
|
||||
|
||||
Future<void> _failOperation(FailOperation event, Emitter<OperationState> emit) async {
|
||||
emit(OperationFailed(message: event.message));
|
||||
|
||||
await Future.delayed(Duration(seconds: 5));
|
||||
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
emit(OperationIdle());
|
||||
}
|
||||
|
||||
Future<void> _finishOperation(FinishOperation event, Emitter<OperationState> emit) async {
|
||||
emit(OperationFinished(message: event.message));
|
||||
|
||||
await Future.delayed(Duration(seconds: 5));
|
||||
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
emit(OperationIdle());
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,5 @@
|
||||
abstract class OperationEvent {}
|
||||
|
||||
class LoadOperation extends OperationEvent {}
|
||||
|
||||
class FailOperation extends OperationEvent {
|
||||
String message;
|
||||
|
||||
|
||||
@ -2,8 +2,6 @@ abstract class OperationState {}
|
||||
|
||||
class OperationIdle extends OperationState {}
|
||||
|
||||
class OperationLoading extends OperationState {}
|
||||
|
||||
class OperationFailed extends OperationState {
|
||||
String message;
|
||||
|
||||
|
||||
@ -4,48 +4,22 @@ 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<OperationViewEnforcer> createState() => _OperationViewEnforcerState();
|
||||
}
|
||||
|
||||
class _OperationViewEnforcerState extends State<OperationViewEnforcer> {
|
||||
OverlayEntry? _overlayEntry;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_overlayEntry?.remove();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<OperationBloc, OperationState>(
|
||||
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) {
|
||||
if (state is OperationFinished && state.message != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.message!)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (state is OperationFailed) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@ -53,20 +27,7 @@ class _OperationViewEnforcerState extends State<OperationViewEnforcer> {
|
||||
);
|
||||
}
|
||||
},
|
||||
child: widget.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),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
20
pubspec.lock
20
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:
|
||||
|
||||
Reference in New Issue
Block a user