Compare commits
3 Commits
8cf0ea4e9a
...
ac6b03227d
| Author | SHA1 | Date | |
|---|---|---|---|
| ac6b03227d | |||
| 2470299a10 | |||
| de8668c11a |
31
CLAUDE.md
Normal file
31
CLAUDE.md
Normal file
@ -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>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'component.dart';
|
||||
|
||||
part 'article.g.dart';
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||
@ -15,6 +17,10 @@ class ArticleDTO {
|
||||
required this.scannedAmount,
|
||||
required this.removeNoteId,
|
||||
required this.taxRate,
|
||||
required this.isParent,
|
||||
this.components,
|
||||
this.warehouseNr,
|
||||
this.warehouseName,
|
||||
});
|
||||
|
||||
String name;
|
||||
@ -27,6 +33,10 @@ class ArticleDTO {
|
||||
String scannedRemovedAmount;
|
||||
String? removeNoteId;
|
||||
bool scannable;
|
||||
bool isParent;
|
||||
List<ComponentDTO>? components;
|
||||
String? warehouseNr;
|
||||
String? warehouseName;
|
||||
|
||||
factory ArticleDTO.fromJson(Map<String, dynamic> json) =>
|
||||
_$ArticleDTOFromJson(json);
|
||||
|
||||
@ -17,6 +17,13 @@ ArticleDTO _$ArticleDTOFromJson(Map<String, dynamic> json) => ArticleDTO(
|
||||
scannedAmount: json['scanned_amount'] as String,
|
||||
removeNoteId: json['remove_note_id'] as String?,
|
||||
taxRate: json['tax_rate'] as String,
|
||||
isParent: json['is_parent'] as bool,
|
||||
components:
|
||||
(json['components'] as List<dynamic>?)
|
||||
?.map((e) => ComponentDTO.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
warehouseNr: json['warehouse_nr'] as String?,
|
||||
warehouseName: json['warehouse_name'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ArticleDTOToJson(ArticleDTO instance) =>
|
||||
@ -31,4 +38,8 @@ Map<String, dynamic> _$ArticleDTOToJson(ArticleDTO instance) =>
|
||||
'scanned_removed_amount': instance.scannedRemovedAmount,
|
||||
'remove_note_id': instance.removeNoteId,
|
||||
'scannable': instance.scannable,
|
||||
'is_parent': instance.isParent,
|
||||
'components': instance.components,
|
||||
'warehouse_nr': instance.warehouseNr,
|
||||
'warehouse_name': instance.warehouseName,
|
||||
};
|
||||
|
||||
23
lib/dto/component.dart
Normal file
23
lib/dto/component.dart
Normal file
@ -0,0 +1,23 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'component.g.dart';
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||
class ComponentDTO {
|
||||
ComponentDTO({
|
||||
required this.articleNr,
|
||||
required this.name,
|
||||
required this.quantity,
|
||||
required this.pos,
|
||||
});
|
||||
|
||||
String articleNr;
|
||||
String name;
|
||||
String quantity;
|
||||
String pos;
|
||||
|
||||
factory ComponentDTO.fromJson(Map<String, dynamic> json) =>
|
||||
_$ComponentDTOFromJson(json);
|
||||
|
||||
Map<dynamic, dynamic> toJson() => _$ComponentDTOToJson(this);
|
||||
}
|
||||
22
lib/dto/component.g.dart
Normal file
22
lib/dto/component.g.dart
Normal file
@ -0,0 +1,22 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'component.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
ComponentDTO _$ComponentDTOFromJson(Map<String, dynamic> json) => ComponentDTO(
|
||||
articleNr: json['article_nr'] as String,
|
||||
name: json['name'] as String,
|
||||
quantity: json['quantity'] as String,
|
||||
pos: json['pos'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ComponentDTOToJson(ComponentDTO instance) =>
|
||||
<String, dynamic>{
|
||||
'article_nr': instance.articleNr,
|
||||
'name': instance.name,
|
||||
'quantity': instance.quantity,
|
||||
'pos': instance.pos,
|
||||
};
|
||||
@ -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,28 +15,27 @@ 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");
|
||||
|
||||
emit(Authenticating());
|
||||
var response = await service.getUserinfo(event.sessionId);
|
||||
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());
|
||||
debugPrint(st.toString());
|
||||
|
||||
emit(Unauthenticated());
|
||||
operationBloc.add(
|
||||
FailOperation(
|
||||
message: "Login war nicht erfolgreich. Probieren Sie es erneut.",
|
||||
@ -46,6 +45,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,15 @@ 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});
|
||||
}
|
||||
|
||||
/// Transient state while [SetAuthenticatedEvent] is being processed and the
|
||||
/// user info is being fetched from the server.
|
||||
class Authenticating extends AuthState {}
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,11 +3,15 @@ import 'package:app_links/app_links.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/bloc/auth_state.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:hl_lieferservice/util.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();
|
||||
@ -58,9 +62,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
// Small delay to ensure listener is ready
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
debugPrint("🔵 Opening browser to: http://localhost:3000/login");
|
||||
|
||||
final loginUrl = Uri.parse('http://100.72.100.33:3000/login');
|
||||
final loginUrl = Uri.parse('${getConfig().backendUrl}/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: [
|
||||
@ -160,17 +176,30 @@ class _LoginPageState extends State<LoginPage> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 15, bottom: 15),
|
||||
child: _isLoading
|
||||
? const Column(
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Warte auf Login...'),
|
||||
],
|
||||
)
|
||||
: OutlinedButton(
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, authState) {
|
||||
final isBusy =
|
||||
_isLoading || authState is Authenticating;
|
||||
if (!isBusy) {
|
||||
return OutlinedButton(
|
||||
onPressed: _onPressLogin,
|
||||
child: const Text("Anmelden mit Holzleitner Login"),
|
||||
child: const Text(
|
||||
"Anmelden mit Holzleitner Login",
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
authState is Authenticating
|
||||
? 'Anmeldung wird abgeschlossen…'
|
||||
: 'Warte auf Login...',
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -180,6 +209,9 @@ class _LoginPageState extends State<LoginPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/feature/car_selection/bloc/bloc.dart
Normal file
89
lib/feature/car_selection/bloc/bloc.dart
Normal file
@ -0,0 +1,89 @@
|
||||
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(event.userId);
|
||||
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(
|
||||
event.userId,
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
31
lib/feature/car_selection/bloc/events.dart
Normal file
31
lib/feature/car_selection/bloc/events.dart
Normal file
@ -0,0 +1,31 @@
|
||||
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
|
||||
/// for the given user.
|
||||
class CarSelectLoad extends CarSelectEvent {
|
||||
final String userId;
|
||||
|
||||
CarSelectLoad({required this.userId});
|
||||
}
|
||||
|
||||
/// Fired when the driver confirms their car choice for the day.
|
||||
class CarSelectConfirm extends CarSelectEvent {
|
||||
final String userId;
|
||||
final Car car;
|
||||
|
||||
CarSelectConfirm({required this.userId, 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});
|
||||
}
|
||||
25
lib/feature/car_selection/bloc/state.dart
Normal file
25
lib/feature/car_selection/bloc/state.dart
Normal file
@ -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,95 @@
|
||||
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_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();
|
||||
final authState = context.read<AuthBloc>().state;
|
||||
if (authState is Authenticated) {
|
||||
context
|
||||
.read<CarSelectBloc>()
|
||||
.add(CarSelectLoad(userId: authState.user.number));
|
||||
}
|
||||
}
|
||||
|
||||
@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: () {
|
||||
final authState = context.read<AuthBloc>().state;
|
||||
if (authState is Authenticated) {
|
||||
context.read<CarSelectBloc>().add(
|
||||
CarSelectLoad(userId: authState.user.number),
|
||||
);
|
||||
}
|
||||
},
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
238
lib/feature/car_selection/presentation/car_selection_page.dart
Normal file
238
lib/feature/car_selection/presentation/car_selection_page.dart
Normal file
@ -0,0 +1,238 @@
|
||||
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;
|
||||
final authState = context.read<AuthBloc>().state as Authenticated;
|
||||
context.read<CarSelectBloc>().add(
|
||||
CarSelectConfirm(
|
||||
userId: authState.user.number,
|
||||
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,36 @@
|
||||
import 'package:hl_lieferservice/feature/cars/model/selection.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class CarSelectionRepository {
|
||||
static String _keyDate(String userId) => 'car_selection_${userId}_date';
|
||||
static String _keyCarId(String userId) => 'car_selection_${userId}_car_id';
|
||||
static String _keyCarPlate(String userId) =>
|
||||
'car_selection_${userId}_car_plate';
|
||||
|
||||
/// Returns the stored [CarSelection] for the given user, or null if nothing
|
||||
/// has been saved yet for that user.
|
||||
Future<CarSelection?> getSelection(String userId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final dateString = prefs.getString(_keyDate(userId));
|
||||
final carId = prefs.getInt(_keyCarId(userId));
|
||||
final plate = prefs.getString(_keyCarPlate(userId));
|
||||
|
||||
if (dateString == null || carId == null || plate == null) return null;
|
||||
|
||||
return CarSelection(
|
||||
date: DateTime.parse(dateString),
|
||||
selectedCarId: carId,
|
||||
selectedCarPlate: plate,
|
||||
);
|
||||
}
|
||||
|
||||
/// Persists the given [selection] for the given user locally on this device.
|
||||
Future<void> saveSelection(String userId, CarSelection selection) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await prefs.setString(_keyDate(userId), selection.date.toIso8601String());
|
||||
await prefs.setInt(_keyCarId(userId), selection.selectedCarId!);
|
||||
await prefs.setString(_keyCarPlate(userId), 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());
|
||||
}
|
||||
}
|
||||
@ -32,8 +51,8 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
|
||||
Future<void> _carAdd(CarAdd event, Emitter<CarsState> emit) async {
|
||||
final currentState = state;
|
||||
|
||||
opBloc.add(StartOperation());
|
||||
try {
|
||||
opBloc.add(LoadOperation());
|
||||
Car newCar = await repository.add(event.teamId, event.plate);
|
||||
|
||||
if (currentState is CarsLoaded) {
|
||||
@ -46,15 +65,15 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _carEdit(CarEdit event, Emitter<CarsState> emit) async {
|
||||
final currentState = state;
|
||||
|
||||
opBloc.add(StartOperation());
|
||||
try {
|
||||
opBloc.add(LoadOperation());
|
||||
await repository.edit(event.teamId, event.newCar);
|
||||
|
||||
if (currentState is CarsLoaded) {
|
||||
@ -74,15 +93,15 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _carDelete(CarDelete event, Emitter<CarsState> emit) async {
|
||||
final currentState = state;
|
||||
|
||||
opBloc.add(StartOperation());
|
||||
try {
|
||||
opBloc.add(LoadOperation());
|
||||
await repository.delete(event.carId, event.teamId);
|
||||
|
||||
if (currentState is CarsLoaded) {
|
||||
@ -100,7 +119,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 {
|
||||
|
||||
14
lib/feature/cars/model/selection.dart
Normal file
14
lib/feature/cars/model/selection.dart
Normal file
@ -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,
|
||||
@ -39,6 +43,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
on<AssignCarEvent>(_assignCar);
|
||||
on<IncrementArticleScanAmount>(_increment);
|
||||
on<ScanArticleEvent>(_scan);
|
||||
on<ScanComponentEvent>(_scanComponent);
|
||||
on<HoldDeliveryEvent>(_holdDelivery);
|
||||
on<CancelDeliveryEvent>(_cancelDelivery);
|
||||
on<ReactivateDeliveryEvent>(_reactivateDelivery);
|
||||
@ -61,17 +66,24 @@ 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());
|
||||
opBloc.add(StartOperation());
|
||||
try {
|
||||
await tourRepository.setArticleAmount(
|
||||
event.deliveryId,
|
||||
@ -79,15 +91,10 @@ 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,9 +133,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));
|
||||
|
||||
for (final delivery in event.tour.deliveries) {
|
||||
try {
|
||||
@ -138,23 +142,14 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
} catch (e, st) {
|
||||
debugPrint("Fehler beim Laden der Distanz: $e");
|
||||
debugPrint("$st");
|
||||
|
||||
// set the distance to none in order to handle the error case
|
||||
// afterwards for that specific delivery
|
||||
distances[delivery.id] = double.nan;
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
RequestSortingInformationEvent(
|
||||
tour: event.tour,
|
||||
payments: event.payments,
|
||||
distances: distances,
|
||||
),
|
||||
);
|
||||
final currentState = state;
|
||||
if (currentState is TourLoaded) {
|
||||
emit(currentState.copyWith(distances: distances));
|
||||
}
|
||||
}
|
||||
|
||||
void _requestSortingInformation(
|
||||
@ -215,9 +210,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
tour: event.tour,
|
||||
paymentOptions: event.payments,
|
||||
sortingInformation: container,
|
||||
distances: event.distances,
|
||||
),
|
||||
);
|
||||
|
||||
add(RequestDeliveryDistanceEvent(tour: event.tour));
|
||||
}
|
||||
|
||||
void _updated(TourUpdated event, Emitter<TourState> emit) {
|
||||
@ -233,14 +229,14 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
paymentOptions: payments,
|
||||
distances: Map<String, double>.from(currentState.distances ?? {}),
|
||||
sortingInformation: currentState.sortingInformation,
|
||||
pendingScanRequests: currentState.pendingScanRequests,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Download distances if tour has previously fetched by API
|
||||
if (currentState is TourLoading) {
|
||||
add(
|
||||
RequestDeliveryDistanceEvent(tour: tour.copyWith(), payments: payments),
|
||||
RequestSortingInformationEvent(tour: tour.copyWith(), payments: payments),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -251,17 +247,13 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
) async {
|
||||
final currentState = state;
|
||||
if (currentState is TourLoaded) {
|
||||
opBloc.add(LoadOperation());
|
||||
opBloc.add(StartOperation());
|
||||
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 +261,13 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
void _holdDelivery(HoldDeliveryEvent event, Emitter<TourState> emit) async {
|
||||
final currentState = state;
|
||||
if (currentState is TourLoaded) {
|
||||
opBloc.add(LoadOperation());
|
||||
opBloc.add(StartOperation());
|
||||
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,26 +278,69 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
) async {
|
||||
final currentState = state;
|
||||
if (currentState is TourLoaded) {
|
||||
opBloc.add(LoadOperation());
|
||||
opBloc.add(StartOperation());
|
||||
try {
|
||||
await tourRepository.cancelDelivery(event.deliveryId);
|
||||
|
||||
opBloc.add(FinishOperation());
|
||||
} catch (e, st) {
|
||||
debugPrint("$e");
|
||||
debugPrint("$st");
|
||||
debugPrint("$e $st");
|
||||
_handleError(e, "Fehler beim Stornieren der Lieferung");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _bumpPendingScans(Emitter<TourState> emit, int delta) {
|
||||
final currentState = state;
|
||||
if (currentState is TourLoaded) {
|
||||
final next = (currentState.pendingScanRequests + delta).clamp(0, 1 << 30);
|
||||
emit(currentState.copyWith(pendingScanRequests: next));
|
||||
}
|
||||
}
|
||||
|
||||
void _scanComponent(
|
||||
ScanComponentEvent event,
|
||||
Emitter<TourState> emit,
|
||||
) async {
|
||||
final currentState = state;
|
||||
|
||||
if (currentState is TourLoaded) {
|
||||
_bumpPendingScans(emit, 1);
|
||||
try {
|
||||
switch (await tourRepository.scanComponent(
|
||||
event.deliveryId,
|
||||
event.carId,
|
||||
event.componentArticleNumber,
|
||||
)) {
|
||||
case ScanResult.scanned:
|
||||
opBloc.add(FinishOperation(message: 'Komponente gescannt'));
|
||||
break;
|
||||
case ScanResult.alreadyScanned:
|
||||
opBloc.add(
|
||||
FailOperation(message: "Fehler beim Zurückstellen der Lieferung"),
|
||||
FailOperation(message: 'Komponente wurde bereits gescannt'),
|
||||
);
|
||||
break;
|
||||
case ScanResult.notFound:
|
||||
opBloc.add(
|
||||
FailOperation(
|
||||
message: 'Komponente ist für keine Lieferung vorgesehen',
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint("FEHLER beim Scannen einer Komponente: $e $st");
|
||||
_handleError(e, "Fehler beim Scannen der Komponente");
|
||||
} finally {
|
||||
_bumpPendingScans(emit, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scan(ScanArticleEvent event, Emitter<TourState> emit) async {
|
||||
final currentState = state;
|
||||
opBloc.add(LoadOperation());
|
||||
|
||||
if (currentState is TourLoaded) {
|
||||
_bumpPendingScans(emit, 1);
|
||||
try {
|
||||
switch (await tourRepository.scanArticle(
|
||||
event.deliveryId,
|
||||
@ -333,9 +364,10 @@ 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");
|
||||
} finally {
|
||||
_bumpPendingScans(emit, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -347,17 +379,18 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
final currentState = state;
|
||||
|
||||
if (currentState is TourLoaded) {
|
||||
opBloc.add(LoadOperation());
|
||||
_bumpPendingScans(emit, 1);
|
||||
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");
|
||||
} finally {
|
||||
_bumpPendingScans(emit, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -365,34 +398,29 @@ 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());
|
||||
opBloc.add(StartOperation());
|
||||
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,9 +429,9 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
Emitter<TourState> emit,
|
||||
) async {
|
||||
final currentState = state;
|
||||
opBloc.add(LoadOperation());
|
||||
|
||||
if (currentState is TourLoaded) {
|
||||
opBloc.add(StartOperation(message: "Lieferung wird abgeschlossen…"));
|
||||
try {
|
||||
await tourRepository.uploadDriverSignature(
|
||||
event.deliveryId,
|
||||
@ -415,11 +443,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
);
|
||||
|
||||
await tourRepository.finishDelivery(event.deliveryId);
|
||||
|
||||
opBloc.add(FinishOperation());
|
||||
opBloc.add(FinishOperation(message: "Lieferung abgeschlossen"));
|
||||
} 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -428,15 +455,13 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
UpdateSelectedPaymentMethodEvent event,
|
||||
Emitter<TourState> emit,
|
||||
) async {
|
||||
opBloc.add(StartOperation());
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -444,8 +469,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
UpdateDeliveryOptionEvent event,
|
||||
Emitter<TourState> emit,
|
||||
) async {
|
||||
opBloc.add(StartOperation());
|
||||
try {
|
||||
opBloc.add(LoadOperation());
|
||||
await tourRepository.updateOption(
|
||||
event.deliveryId,
|
||||
event.key,
|
||||
@ -453,10 +478,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
);
|
||||
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,10 +487,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
UpdateDiscountEvent event,
|
||||
Emitter<TourState> emit,
|
||||
) async {
|
||||
opBloc.add(LoadOperation());
|
||||
|
||||
opBloc.add(StartOperation());
|
||||
try {
|
||||
opBloc.add(FinishOperation());
|
||||
await tourRepository.updateDiscount(
|
||||
event.deliveryId,
|
||||
event.reason,
|
||||
@ -475,15 +496,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
);
|
||||
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 +505,33 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
RemoveDiscountEvent event,
|
||||
Emitter<TourState> emit,
|
||||
) async {
|
||||
opBloc.add(LoadOperation());
|
||||
|
||||
opBloc.add(StartOperation());
|
||||
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());
|
||||
|
||||
opBloc.add(StartOperation());
|
||||
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());
|
||||
|
||||
opBloc.add(StartOperation());
|
||||
try {
|
||||
await tourRepository.unscan(
|
||||
event.deliveryId,
|
||||
@ -545,27 +541,19 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
||||
);
|
||||
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());
|
||||
|
||||
opBloc.add(StartOperation());
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,20 +15,17 @@ class LoadTour extends TourEvent {
|
||||
|
||||
class RequestDeliveryDistanceEvent extends TourEvent {
|
||||
Tour tour;
|
||||
List<Payment> payments;
|
||||
|
||||
RequestDeliveryDistanceEvent({required this.tour, required this.payments});
|
||||
RequestDeliveryDistanceEvent({required this.tour});
|
||||
}
|
||||
|
||||
class RequestSortingInformationEvent extends TourEvent {
|
||||
Tour tour;
|
||||
List<Payment> payments;
|
||||
Map<String, double>? distances;
|
||||
|
||||
RequestSortingInformationEvent({
|
||||
required this.tour,
|
||||
required this.payments,
|
||||
this.distances,
|
||||
});
|
||||
}
|
||||
|
||||
@ -90,6 +87,20 @@ class ScanArticleEvent extends TourEvent {
|
||||
String carId;
|
||||
}
|
||||
|
||||
/// Scan a single BOM component. The server call for the parent article is
|
||||
/// deferred until *all* components are fully scanned.
|
||||
class ScanComponentEvent extends TourEvent {
|
||||
ScanComponentEvent({
|
||||
required this.componentArticleNumber,
|
||||
required this.carId,
|
||||
required this.deliveryId,
|
||||
});
|
||||
|
||||
String componentArticleNumber;
|
||||
String deliveryId;
|
||||
String carId;
|
||||
}
|
||||
|
||||
class CancelDeliveryEvent extends TourEvent {
|
||||
String deliveryId;
|
||||
|
||||
|
||||
@ -8,49 +8,38 @@ class TourLoading extends TourState {}
|
||||
|
||||
class TourLoadingFailed extends TourState {}
|
||||
|
||||
class TourRequestingDistances extends TourState {
|
||||
Tour tour;
|
||||
List<Payment> payments;
|
||||
|
||||
TourRequestingDistances({required this.tour, required this.payments});
|
||||
}
|
||||
|
||||
class TourRequestingSortingInformation extends TourState {
|
||||
Tour tour;
|
||||
Map<String, double>? distances;
|
||||
List<Payment> paymentOptions;
|
||||
|
||||
TourRequestingSortingInformation({
|
||||
required this.tour,
|
||||
this.distances,
|
||||
required this.paymentOptions,
|
||||
});
|
||||
}
|
||||
|
||||
class TourLoaded extends TourState {
|
||||
Tour tour;
|
||||
Map<String, double>? distances;
|
||||
List<Payment> paymentOptions;
|
||||
Map<String, List<String>> sortingInformation;
|
||||
|
||||
/// Number of scan-related server requests currently in flight. Drives the
|
||||
/// inline indicator on the scanner widget. Using a counter (not bool) lets
|
||||
/// rapid-fire scans coexist without one prematurely clearing the indicator.
|
||||
int pendingScanRequests;
|
||||
|
||||
TourLoaded({
|
||||
required this.tour,
|
||||
this.distances,
|
||||
required this.paymentOptions,
|
||||
required this.sortingInformation
|
||||
required this.sortingInformation,
|
||||
this.pendingScanRequests = 0,
|
||||
});
|
||||
|
||||
TourLoaded copyWith({
|
||||
Tour? tour,
|
||||
Map<String, double>? distances,
|
||||
List<Payment>? paymentOptions,
|
||||
Map<String, List<String>>? sortingInformation
|
||||
Map<String, List<String>>? sortingInformation,
|
||||
int? pendingScanRequests,
|
||||
}) {
|
||||
return TourLoaded(
|
||||
tour: tour ?? this.tour,
|
||||
distances: distances ?? this.distances,
|
||||
paymentOptions: paymentOptions ?? this.paymentOptions,
|
||||
sortingInformation: sortingInformation ?? this.sortingInformation
|
||||
sortingInformation: sortingInformation ?? this.sortingInformation,
|
||||
pendingScanRequests: pendingScanRequests ?? this.pendingScanRequests,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,96 +94,79 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
|
||||
RemoveImageNote event,
|
||||
Emitter<NoteState> emit,
|
||||
) async {
|
||||
opBloc.add(LoadOperation());
|
||||
|
||||
opBloc.add(StartOperation());
|
||||
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());
|
||||
|
||||
opBloc.add(StartOperation());
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load(LoadNote event, Emitter<NoteState> emit) async {
|
||||
if (state is NoteLoaded || state is NoteLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit.call(NoteLoading());
|
||||
|
||||
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());
|
||||
|
||||
opBloc.add(StartOperation());
|
||||
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());
|
||||
|
||||
opBloc.add(StartOperation());
|
||||
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());
|
||||
|
||||
opBloc.add(StartOperation());
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,8 +91,10 @@ class _ResetArticleAmountDialogState extends State<ResetArticleAmountDialog> {
|
||||
children: [
|
||||
const Text("Wollen Sie die entfernten Artikel wieder hinzufügen?"),
|
||||
!widget.article.scannable ? _amountSelection() : Container(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.spaceEvenly,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: _reset,
|
||||
|
||||
@ -154,8 +154,10 @@ class _ArticleUnscanDialogState extends State<ArticleUnscanDialog> {
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.spaceAround,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: isValidText ? _unscan : null,
|
||||
|
||||
@ -142,7 +142,9 @@ class _DeliveryDetailState extends State<DeliveryDetail> {
|
||||
}
|
||||
|
||||
Widget _stepsNavigation(Delivery delivery) {
|
||||
return SizedBox(
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 90,
|
||||
child: Row(
|
||||
@ -170,22 +172,26 @@ class _DeliveryDetailState extends State<DeliveryDetail> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<TourBloc, TourState>(
|
||||
builder: (context, state) {
|
||||
Delivery? delivery;
|
||||
if (state is TourLoaded) {
|
||||
delivery = state.tour.deliveries.firstWhere(
|
||||
(d) => d.id == widget.deliveryId,
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Auslieferungsdetails")),
|
||||
body: BlocBuilder<TourBloc, TourState>(
|
||||
builder: (context, state) {
|
||||
final currentState = state;
|
||||
|
||||
if (currentState is TourLoaded) {
|
||||
Delivery delivery = currentState.tour.deliveries.firstWhere(
|
||||
(delivery) => delivery.id == widget.deliveryId,
|
||||
);
|
||||
return Column(
|
||||
body: delivery == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
children: [
|
||||
_stepInfo(),
|
||||
const Divider(),
|
||||
@ -194,14 +200,12 @@ class _DeliveryDetailState extends State<DeliveryDetail> {
|
||||
StepFactory().make(_step, delivery) ??
|
||||
_stepMissingWarning(),
|
||||
),
|
||||
_stepsNavigation(delivery),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
),
|
||||
bottomNavigationBar:
|
||||
delivery == null ? null : _stepsNavigation(delivery),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,18 +195,17 @@ class _DeliveryDiscountState extends State<DeliveryDiscount> {
|
||||
},
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: FilledButton(
|
||||
FilledButton(
|
||||
onPressed:
|
||||
!_isReasonEmpty && _discountValue > 0
|
||||
? _updateValues
|
||||
: null,
|
||||
child: const Text("Speichern"),
|
||||
),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: _discountValue > 0 && widget.discount != null ? _resetValues : null,
|
||||
child: const Text("Gutschrift entfernen"),
|
||||
|
||||
@ -9,6 +9,8 @@ import 'package:hl_lieferservice/model/delivery.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:signature/signature.dart';
|
||||
|
||||
enum _SigningPhase { customerAcceptance, customerSignature, driverSignature }
|
||||
|
||||
class SignatureView extends StatefulWidget {
|
||||
const SignatureView({
|
||||
super.key,
|
||||
@ -43,33 +45,11 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
exportBackgroundColor: Colors.white,
|
||||
);
|
||||
|
||||
bool _isDriverSigning = false;
|
||||
bool _customerAccepted = false;
|
||||
bool _noteAccepted = false;
|
||||
bool _notesEmpty = true;
|
||||
bool _isCustomerSignatureEmpty = true;
|
||||
bool _isDriverSignatureEmpty = true;
|
||||
_SigningPhase _phase = _SigningPhase.customerAcceptance;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_customerController.addListener(() {
|
||||
if (_isCustomerSignatureEmpty != _customerController.isEmpty) {
|
||||
setState(() {
|
||||
_isCustomerSignatureEmpty = _customerController.isEmpty;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_driverController.addListener(() {
|
||||
if (_isDriverSignatureEmpty != _driverController.isEmpty) {
|
||||
setState(() {
|
||||
_isDriverSignatureEmpty = _driverController.isEmpty;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
context.read<NoteBloc>().add(LoadNote(delivery: widget.delivery));
|
||||
}
|
||||
|
||||
@ -80,14 +60,88 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _signatureField() {
|
||||
return Signature(
|
||||
controller: _isDriverSigning ? _driverController : _customerController,
|
||||
backgroundColor: Colors.white,
|
||||
void _onAcceptanceDone() {
|
||||
setState(() => _phase = _SigningPhase.customerSignature);
|
||||
}
|
||||
|
||||
void _onCustomerSigned() {
|
||||
setState(() => _phase = _SigningPhase.driverSignature);
|
||||
}
|
||||
|
||||
Future<void> _onDriverSigned() async {
|
||||
widget.onSigned(
|
||||
(await _customerController.toPngBytes())!,
|
||||
(await _driverController.toPngBytes())!,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _notes() {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (_phase) {
|
||||
_SigningPhase.customerAcceptance => _AcceptanceStep(
|
||||
onContinue: _onAcceptanceDone,
|
||||
),
|
||||
_SigningPhase.customerSignature => _SignaturePadStep(
|
||||
controller: _customerController,
|
||||
delivery: widget.delivery,
|
||||
appBarTitle: "Unterschrift des Kunden",
|
||||
buttonLabel: "Weiter",
|
||||
onContinue: _onCustomerSigned,
|
||||
),
|
||||
_SigningPhase.driverSignature => _SignaturePadStep(
|
||||
controller: _driverController,
|
||||
delivery: widget.delivery,
|
||||
appBarTitle: "Unterschrift des Fahrers",
|
||||
buttonLabel: "Absenden",
|
||||
onContinue: _onDriverSigned,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _AcceptanceStep extends StatefulWidget {
|
||||
const _AcceptanceStep({required this.onContinue});
|
||||
|
||||
final VoidCallback onContinue;
|
||||
|
||||
@override
|
||||
State<_AcceptanceStep> createState() => _AcceptanceStepState();
|
||||
}
|
||||
|
||||
class _AcceptanceStepState extends State<_AcceptanceStep> {
|
||||
bool _customerAccepted = false;
|
||||
bool _noteAccepted = false;
|
||||
|
||||
Widget _notesContent(NoteState noteState) {
|
||||
if (noteState is! NoteLoaded) {
|
||||
return const SizedBox(
|
||||
width: double.infinity,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (noteState.notes.isEmpty) {
|
||||
return const SizedBox(
|
||||
width: double.infinity,
|
||||
child: Center(child: Text("Keine Notizen vorhanden")),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.event_note_outlined),
|
||||
title: Text(noteState.notes[index].content),
|
||||
contentPadding: const EdgeInsets.all(20),
|
||||
tileColor: Theme.of(context).colorScheme.onSecondary,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const Divider(height: 0),
|
||||
itemCount: noteState.notes.length,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _notes(NoteState noteState) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -98,68 +152,32 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
BlocConsumer<NoteBloc, NoteState>(
|
||||
listener: (context, state) {
|
||||
final current = state;
|
||||
if (current is NoteLoaded) {
|
||||
setState(() {
|
||||
_notesEmpty = current.notes.isEmpty;
|
||||
});
|
||||
}
|
||||
|
||||
if (current is NoteLoadedBase) {
|
||||
setState(() {
|
||||
_notesEmpty = current.notes.isEmpty;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
builder: (context, state) {
|
||||
final current = state;
|
||||
|
||||
if (current is NoteLoaded) {
|
||||
if (current.notes.isEmpty) {
|
||||
return const SizedBox(
|
||||
width: double.infinity,
|
||||
child: Center(child: Text("Keine Notizen vorhanden")),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.event_note_outlined),
|
||||
title: Text(current.notes[index].content),
|
||||
contentPadding: const EdgeInsets.all(20),
|
||||
tileColor: Theme.of(context).colorScheme.onSecondary,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const Divider(height: 0),
|
||||
itemCount: current.notes.length,
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox(
|
||||
width: double.infinity,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
_notesContent(noteState),
|
||||
const Padding(padding: EdgeInsets.only(top: 25), child: Divider()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _customerCheckboxes() {
|
||||
return !_isDriverSigning
|
||||
? Column(
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<NoteBloc, NoteState>(
|
||||
builder: (context, noteState) {
|
||||
final notesEmpty = switch (noteState) {
|
||||
NoteLoadedBase(notes: final ns) => ns.isEmpty,
|
||||
_ => true,
|
||||
};
|
||||
final isButtonEnabled =
|
||||
_customerAccepted && (_noteAccepted || notesEmpty);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Unterschrift des Kunden")),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 25, bottom: 0),
|
||||
child: _notes(),
|
||||
child: _notes(noteState),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 25.0, bottom: 0),
|
||||
@ -167,8 +185,7 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _noteAccepted,
|
||||
onChanged:
|
||||
_notesEmpty
|
||||
onChanged: notesEmpty
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
@ -178,7 +195,9 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
),
|
||||
Flexible(
|
||||
child: InkWell(
|
||||
onTap: _notesEmpty ? null : () {
|
||||
onTap: notesEmpty
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_noteAccepted = !_noteAccepted;
|
||||
});
|
||||
@ -221,40 +240,83 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Container();
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 90,
|
||||
child: Center(
|
||||
child: FilledButton(
|
||||
onPressed: isButtonEnabled ? widget.onContinue : null,
|
||||
child: const Text("Unterschreiben"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SignaturePadStep extends StatefulWidget {
|
||||
const _SignaturePadStep({
|
||||
required this.controller,
|
||||
required this.delivery,
|
||||
required this.appBarTitle,
|
||||
required this.buttonLabel,
|
||||
required this.onContinue,
|
||||
});
|
||||
|
||||
final SignatureController controller;
|
||||
final Delivery delivery;
|
||||
final String appBarTitle;
|
||||
final String buttonLabel;
|
||||
final VoidCallback onContinue;
|
||||
|
||||
@override
|
||||
State<_SignaturePadStep> createState() => _SignaturePadStepState();
|
||||
}
|
||||
|
||||
class _SignaturePadStepState extends State<_SignaturePadStep> {
|
||||
bool _isEmpty = true;
|
||||
late final VoidCallback _listener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isEmpty = widget.controller.isEmpty;
|
||||
_listener = () {
|
||||
if (_isEmpty != widget.controller.isEmpty) {
|
||||
setState(() {
|
||||
_isEmpty = widget.controller.isEmpty;
|
||||
});
|
||||
}
|
||||
};
|
||||
widget.controller.addListener(_listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_listener);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String formattedDate = DateFormat("dd.MM.yyyy").format(DateTime.now());
|
||||
|
||||
bool isButtonEnabled;
|
||||
if (!_isDriverSigning) {
|
||||
isButtonEnabled =
|
||||
_customerAccepted &&
|
||||
(_noteAccepted || _notesEmpty) &&
|
||||
!_isCustomerSignatureEmpty;
|
||||
} else {
|
||||
isButtonEnabled = !_isDriverSignatureEmpty;
|
||||
}
|
||||
final formattedDate = DateFormat("dd.MM.yyyy").format(DateTime.now());
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title:
|
||||
!_isDriverSigning
|
||||
? const Text("Unterschrift des Kunden")
|
||||
: const Text("Unterschrift des Fahrers"),
|
||||
),
|
||||
appBar: AppBar(title: Text(widget.appBarTitle)),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: ListView(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height:
|
||||
MediaQuery.of(context).size.height *
|
||||
(_isDriverSigning ? 0.75 : 0.5),
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(color: Colors.white),
|
||||
child: Padding(
|
||||
@ -272,7 +334,12 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Expanded(child: _signatureField()),
|
||||
Expanded(
|
||||
child: Signature(
|
||||
controller: widget.controller,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -285,36 +352,22 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
),
|
||||
),
|
||||
),
|
||||
_customerCheckboxes(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 25.0, bottom: 25.0),
|
||||
child: Center(
|
||||
child: FilledButton(
|
||||
onPressed:
|
||||
isButtonEnabled
|
||||
? () async {
|
||||
if (!_isDriverSigning) {
|
||||
setState(() {
|
||||
_isDriverSigning = true;
|
||||
});
|
||||
} else {
|
||||
widget.onSigned(
|
||||
(await _customerController.toPngBytes())!,
|
||||
(await _driverController.toPngBytes())!,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child:
|
||||
!_isDriverSigning
|
||||
? const Text("Weiter")
|
||||
: const Text("Absenden"),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 90,
|
||||
child: Center(
|
||||
child: FilledButton(
|
||||
onPressed: _isEmpty ? null : widget.onContinue,
|
||||
child: Text(widget.buttonLabel),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,6 +51,10 @@ class _NoteAddDialogState extends State<NoteAddDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
// Default Dialog.insetPadding eats 80 dp horizontally on phones, leaving
|
||||
// too little room for two side-by-side buttons on narrow devices like
|
||||
// the Samsung A16F. Shrinking the inset gives back ~64 dp.
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
height: MediaQuery.of(context).size.height * 0.6,
|
||||
@ -115,8 +119,9 @@ class _NoteAddDialogState extends State<NoteAddDialog> {
|
||||
maxLines: 10,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed:
|
||||
@ -126,16 +131,13 @@ class _NoteAddDialogState extends State<NoteAddDialog> {
|
||||
: null,
|
||||
child: const Text("Hinzufügen"),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: OutlinedButton(
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
_noteController.clear();
|
||||
_noteSelectionController.clear();
|
||||
},
|
||||
child: const Text("Zurücksetzen"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -10,68 +11,143 @@ import '../../detail/service/notes_service.dart';
|
||||
|
||||
class DeliveryListItem extends StatelessWidget {
|
||||
final Delivery delivery;
|
||||
final double distance;
|
||||
final double? distance;
|
||||
|
||||
const DeliveryListItem({
|
||||
super.key,
|
||||
required this.delivery,
|
||||
required this.distance,
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(
|
||||
delivery.customer.name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
leading: _leading(context),
|
||||
tileColor: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
subtitle: Text(delivery.customer.address.toString()),
|
||||
trailing: Icon(Icons.arrow_forward_ios),
|
||||
onTap: () => _goToDelivery(context),
|
||||
(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:
|
||||
final distanceLabel = distance != null && !distance!.isNaN
|
||||
? "${distance!.toStringAsFixed(1)} km"
|
||||
: "–";
|
||||
return (
|
||||
Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
Colors.transparent,
|
||||
Icons.local_shipping_outlined,
|
||||
distanceLabel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (cardColor, borderColor, icon, statusLabel) = _stateStyle(context);
|
||||
final isOngoing = delivery.state == DeliveryState.ongoing;
|
||||
|
||||
final iconColor = switch (delivery.state) {
|
||||
DeliveryState.finished => Colors.green,
|
||||
DeliveryState.canceled => Colors.red,
|
||||
DeliveryState.onhold => Colors.orange,
|
||||
DeliveryState.ongoing => Theme.of(context).primaryColor,
|
||||
};
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
elevation: 0,
|
||||
color: cardColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: borderColor),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => _goToDelivery(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: iconColor, size: 28),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
delivery.customer.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isOngoing ? null : iconColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
delivery.customer.address.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
statusLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isOngoing
|
||||
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||
: iconColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -43,7 +43,7 @@ class _DeliveryListState extends State<DeliveryList> {
|
||||
|
||||
return DeliveryListItem(
|
||||
delivery: delivery,
|
||||
distance: distances[delivery.id] ?? 0.0,
|
||||
distance: distances[delivery.id],
|
||||
);
|
||||
},
|
||||
itemCount: sortingInformation.length,
|
||||
@ -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],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
@ -16,11 +18,9 @@ class DeliveryOverview extends StatefulWidget {
|
||||
const DeliveryOverview({
|
||||
super.key,
|
||||
required this.tour,
|
||||
required this.distances,
|
||||
});
|
||||
|
||||
final Tour tour;
|
||||
final Map<String, double> distances;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DeliveryOverviewState();
|
||||
@ -34,8 +34,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 +50,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 +57,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 +155,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,8 +1,10 @@
|
||||
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';
|
||||
|
||||
import 'package:hl_lieferservice/model/tour.dart';
|
||||
import '../../bloc/tour_bloc.dart';
|
||||
import '../../bloc/tour_state.dart';
|
||||
|
||||
@ -14,27 +16,90 @@ class DeliveryOverviewPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
|
||||
Widget _buildOverviewWithBanner({
|
||||
required Tour tour,
|
||||
required String bannerText,
|
||||
}) {
|
||||
return Column(
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.amber.shade100,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(bannerText)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: DeliveryOverview(tour: tour),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@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 ?? {},
|
||||
),
|
||||
if (state.distances == null) {
|
||||
return _buildOverviewWithBanner(
|
||||
tour: state.tour,
|
||||
bannerText: "Berechne Distanzen…",
|
||||
);
|
||||
}
|
||||
return DeliveryOverview(tour: state.tour);
|
||||
}
|
||||
|
||||
if (state is TourLoadingFailed) {
|
||||
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 {
|
||||
@ -90,6 +92,48 @@ class TourRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a single BOM component locally. The server-side `scanArticle` call
|
||||
/// for the parent article is deferred until **every** component of the
|
||||
/// parent is fully scanned — only then does the parent count as loaded.
|
||||
Future<ScanResult> scanComponent(
|
||||
String deliveryId,
|
||||
String carId,
|
||||
String componentArticleNumber,
|
||||
) async {
|
||||
if (!_tourStream.hasValue) {
|
||||
throw TourNotFoundException();
|
||||
}
|
||||
|
||||
final tour = _tourStream.value!;
|
||||
final delivery = tour.deliveries.firstWhere(
|
||||
(d) => d.id == deliveryId,
|
||||
);
|
||||
|
||||
// Locate the parent article and the matching component.
|
||||
final parentArticle = delivery.findParentOfComponent(
|
||||
componentArticleNumber,
|
||||
);
|
||||
if (parentArticle == null) return ScanResult.notFound;
|
||||
|
||||
final component = parentArticle.findComponent(componentArticleNumber)!;
|
||||
|
||||
if (component.isFullyScanned) return ScanResult.alreadyScanned;
|
||||
|
||||
// ── Local-only increment ──
|
||||
component.scannedAmount += 1;
|
||||
|
||||
// ── When every component is done, sync the parent with the server ──
|
||||
if (parentArticle.isFullyScanned) {
|
||||
await service.scanArticle(parentArticle.internalId.toString());
|
||||
parentArticle.scannedAmount += 1;
|
||||
delivery.carId = int.tryParse(carId) ?? delivery.carId;
|
||||
await service.assignCar(deliveryId, carId);
|
||||
}
|
||||
|
||||
_tourStream.add(tour);
|
||||
return ScanResult.scanned;
|
||||
}
|
||||
|
||||
Future<void> unscan(
|
||||
String deliveryId,
|
||||
String articleId,
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import 'package:hl_lieferservice/dto/article.dart';
|
||||
|
||||
import 'component.dart';
|
||||
|
||||
class Article {
|
||||
Article({
|
||||
required this.name,
|
||||
@ -11,13 +13,21 @@ class Article {
|
||||
required this.scannable,
|
||||
required this.scannedAmount,
|
||||
required this.scannedRemovedAmount,
|
||||
required this.isParent,
|
||||
this.components = const [],
|
||||
this.scannedDate,
|
||||
this.removeNoteId
|
||||
this.removeNoteId,
|
||||
this.warehouseNr,
|
||||
this.warehouseName,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String articleNumber;
|
||||
final int internalId;
|
||||
final bool isParent;
|
||||
final List<Component> components;
|
||||
final String? warehouseNr;
|
||||
final String? warehouseName;
|
||||
|
||||
int amount;
|
||||
double price;
|
||||
@ -36,7 +46,35 @@ class Article {
|
||||
return price * scannedAmount * ((100 + tax) / 100);
|
||||
}
|
||||
|
||||
/// Whether this article is fully scanned.
|
||||
///
|
||||
/// For parent articles (Stückliste): delegates to components — all must be
|
||||
/// individually scanned. For regular articles: the classic amount check.
|
||||
bool get isFullyScanned {
|
||||
if (isParent && components.isNotEmpty) {
|
||||
return components.every((c) => c.isFullyScanned);
|
||||
}
|
||||
return scannedAmount + scannedRemovedAmount >= amount;
|
||||
}
|
||||
|
||||
/// Find a component by its article number, or `null` if none matches.
|
||||
Component? findComponent(String articleNumber) {
|
||||
for (final c in components) {
|
||||
if (c.articleNumber == articleNumber) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Whether this article *or* any of its components carries [articleNumber].
|
||||
bool hasArticleNumber(String articleNumber) {
|
||||
if (this.articleNumber == articleNumber) return true;
|
||||
return components.any((c) => c.articleNumber == articleNumber);
|
||||
}
|
||||
|
||||
bool unscanned() {
|
||||
if (isParent && components.isNotEmpty) {
|
||||
return components.every((c) => c.scannedAmount == 0);
|
||||
}
|
||||
return scannedAmount == 0;
|
||||
}
|
||||
|
||||
@ -58,6 +96,11 @@ class Article {
|
||||
price: double.parse(dto.price == "" ? "0.0" : dto.price),
|
||||
scannable: dto.scannable,
|
||||
tax: double.parse(dto.taxRate == "" ? "19" : dto.taxRate),
|
||||
isParent: dto.isParent,
|
||||
components: dto.components?.map(Component.fromDTO).toList() ?? [],
|
||||
warehouseNr: dto.warehouseNr?.isEmpty ?? true ? null : dto.warehouseNr,
|
||||
warehouseName:
|
||||
dto.warehouseName?.isEmpty ?? true ? null : dto.warehouseName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
34
lib/model/component.dart
Normal file
34
lib/model/component.dart
Normal file
@ -0,0 +1,34 @@
|
||||
import 'package:hl_lieferservice/dto/component.dart';
|
||||
|
||||
class Component {
|
||||
Component({
|
||||
required this.articleNumber,
|
||||
required this.name,
|
||||
required this.quantity,
|
||||
required this.position,
|
||||
this.scannedAmount = 0,
|
||||
});
|
||||
|
||||
final String articleNumber;
|
||||
final String name;
|
||||
final double quantity;
|
||||
final double position;
|
||||
|
||||
int scannedAmount;
|
||||
|
||||
/// Required scan count derived from BOM quantity (e.g. 7.0 → 7).
|
||||
int get requiredAmount => quantity.ceil();
|
||||
|
||||
bool get isFullyScanned => scannedAmount >= requiredAmount;
|
||||
|
||||
bool get needsScanning => scannedAmount < requiredAmount;
|
||||
|
||||
factory Component.fromDTO(ComponentDTO dto) {
|
||||
return Component(
|
||||
articleNumber: dto.articleNr,
|
||||
name: dto.name,
|
||||
quantity: double.tryParse(dto.quantity) ?? 0.0,
|
||||
position: double.tryParse(dto.pos) ?? 0.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -295,29 +295,42 @@ class Delivery implements Comparable<Delivery> {
|
||||
|
||||
List<Article> getDeliveredArticles() {
|
||||
return articles
|
||||
.where(
|
||||
(article) => article.scannedAmount > 0 || !article.scannable,
|
||||
)
|
||||
.where((article) {
|
||||
if (!article.scannable) return true;
|
||||
if (article.isParent && article.components.isNotEmpty) {
|
||||
return article.isFullyScanned;
|
||||
}
|
||||
return article.scannedAmount > 0;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
bool containsArticle(String articleNr) {
|
||||
return articles.any((article) => article.articleNumber == articleNr);
|
||||
return articles.any((article) => article.hasArticleNumber(articleNr));
|
||||
}
|
||||
|
||||
Article getArticle(String nr) {
|
||||
return articles.firstWhere((article) => article.articleNumber == nr);
|
||||
}
|
||||
|
||||
/// Find the parent article whose BOM contains [componentArticleNr].
|
||||
Article? findParentOfComponent(String componentArticleNr) {
|
||||
for (final article in articles) {
|
||||
if (article.isParent &&
|
||||
article.findComponent(componentArticleNr) != null) {
|
||||
return article;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Article> getScannableArticles() {
|
||||
return articles.where((article) => article.scannable).toList();
|
||||
}
|
||||
|
||||
bool allArticlesScanned() {
|
||||
return getScannableArticles().every(
|
||||
(article) =>
|
||||
article.amount ==
|
||||
article.scannedAmount + article.scannedRemovedAmount,
|
||||
(article) => article.isFullyScanned,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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,15 +52,31 @@ 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(
|
||||
child: BlocBuilder<AppBloc, AppState>(
|
||||
// Wrap the Navigator (not just the home route) so the loading
|
||||
// overlay covers every pushed route — DeliveryDetail, Cars,
|
||||
// dialogs, etc. — not only the initial home tree.
|
||||
builder: (context, child) =>
|
||||
OperationViewEnforcer(child: child ?? const SizedBox.shrink()),
|
||||
home: BlocBuilder<AppBloc, AppState>(
|
||||
builder: (context, state) {
|
||||
if (state is AppConfigLoading) {
|
||||
return Scaffold(
|
||||
@ -67,13 +89,14 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
||||
}
|
||||
|
||||
if (state is AppConfigLoaded) {
|
||||
return LoginEnforcer(child: Home());
|
||||
return LoginEnforcer(
|
||||
child: CarSelectionEnforcer(child: Home()),
|
||||
);
|
||||
}
|
||||
|
||||
return Container();
|
||||
},
|
||||
),
|
||||
),
|
||||
routes: {"/cars": (context) => CarManagementPage()},
|
||||
),
|
||||
);
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -3,29 +3,79 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
|
||||
import 'package:hl_lieferservice/widget/operations/bloc/operation_state.dart';
|
||||
|
||||
class OperationBloc extends Bloc<OperationEvent, OperationState> {
|
||||
/// Counts how many in-flight mutations want to show the blocking overlay.
|
||||
/// Allows multiple parallel mutations without one prematurely closing the
|
||||
/// overlay before the others complete.
|
||||
int _inFlightCount = 0;
|
||||
|
||||
/// When the current overlay session began (set when [_inFlightCount]
|
||||
/// transitions 0 → 1). Used to enforce [_minimumDisplayDuration].
|
||||
DateTime? _overlayStartedAt;
|
||||
|
||||
/// Minimum time the overlay stays visible, even if the underlying request
|
||||
/// completes faster. Prevents a "did anything happen?" UX where a sub-100 ms
|
||||
/// roundtrip flashes the overlay for one frame.
|
||||
static const Duration _minimumDisplayDuration = Duration(milliseconds: 350);
|
||||
|
||||
OperationBloc() : super(OperationIdle()) {
|
||||
on<LoadOperation>(_loadOperation);
|
||||
on<StartOperation>(_startOperation);
|
||||
on<FailOperation>(_failOperation);
|
||||
on<FinishOperation>(_finishOperation);
|
||||
}
|
||||
|
||||
Future<void> _loadOperation(LoadOperation event, Emitter<OperationState> emit) async {
|
||||
emit(OperationLoading());
|
||||
Future<void> _startOperation(
|
||||
StartOperation event,
|
||||
Emitter<OperationState> emit,
|
||||
) async {
|
||||
if (_inFlightCount == 0) {
|
||||
_overlayStartedAt = DateTime.now();
|
||||
}
|
||||
_inFlightCount += 1;
|
||||
emit(OperationInProgress(message: event.message));
|
||||
}
|
||||
|
||||
Future<void> _failOperation(FailOperation event, Emitter<OperationState> emit) async {
|
||||
emit(OperationFailed(message: event.message));
|
||||
Future<void> _finishOperation(
|
||||
FinishOperation event,
|
||||
Emitter<OperationState> emit,
|
||||
) async {
|
||||
_inFlightCount = (_inFlightCount - 1).clamp(0, 1 << 30);
|
||||
|
||||
await Future.delayed(Duration(seconds: 5));
|
||||
|
||||
emit(OperationIdle());
|
||||
}
|
||||
|
||||
Future<void> _finishOperation(FinishOperation event, Emitter<OperationState> emit) async {
|
||||
if (event.message != null) {
|
||||
emit(OperationFinished(message: event.message));
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
}
|
||||
|
||||
await Future.delayed(Duration(seconds: 5));
|
||||
|
||||
if (_inFlightCount > 0) {
|
||||
emit(OperationInProgress());
|
||||
} else {
|
||||
await _awaitMinimumOverlayDuration();
|
||||
_overlayStartedAt = null;
|
||||
emit(OperationIdle());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _failOperation(
|
||||
FailOperation event,
|
||||
Emitter<OperationState> emit,
|
||||
) async {
|
||||
_inFlightCount = (_inFlightCount - 1).clamp(0, 1 << 30);
|
||||
emit(OperationFailed(message: event.message));
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
|
||||
if (_inFlightCount > 0) {
|
||||
emit(OperationInProgress());
|
||||
} else {
|
||||
_overlayStartedAt = null;
|
||||
emit(OperationIdle());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _awaitMinimumOverlayDuration() async {
|
||||
final startedAt = _overlayStartedAt;
|
||||
if (startedAt == null) return;
|
||||
final elapsed = DateTime.now().difference(startedAt);
|
||||
if (elapsed < _minimumDisplayDuration) {
|
||||
await Future.delayed(_minimumDisplayDuration - elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
abstract class OperationEvent {}
|
||||
|
||||
class LoadOperation extends OperationEvent {}
|
||||
class StartOperation extends OperationEvent {
|
||||
String? message;
|
||||
|
||||
StartOperation({this.message});
|
||||
}
|
||||
|
||||
class FailOperation extends OperationEvent {
|
||||
String message;
|
||||
|
||||
@ -2,7 +2,11 @@ abstract class OperationState {}
|
||||
|
||||
class OperationIdle extends OperationState {}
|
||||
|
||||
class OperationLoading extends OperationState {}
|
||||
class OperationInProgress extends OperationState {
|
||||
String? message;
|
||||
|
||||
OperationInProgress({this.message});
|
||||
}
|
||||
|
||||
class OperationFailed extends OperationState {
|
||||
String message;
|
||||
|
||||
@ -4,48 +4,25 @@ 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.
|
||||
/// - A blocking modal barrier with a spinner while a mutation is in flight,
|
||||
/// so the user gets unambiguous "wait" feedback and cannot double-tap or
|
||||
/// navigate away mid-request.
|
||||
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>(
|
||||
return BlocConsumer<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 +30,44 @@ class _OperationViewEnforcerState extends State<OperationViewEnforcer> {
|
||||
);
|
||||
}
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
builder: (context, state) {
|
||||
final isInProgress = state is OperationInProgress;
|
||||
final progressMessage =
|
||||
isInProgress ? state.message : null;
|
||||
|
||||
OverlayEntry _createOverlayEntry(BuildContext context) {
|
||||
return OverlayEntry(
|
||||
builder: (context) => DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color.fromRGBO(128, 128, 128, 0.8),
|
||||
return Stack(
|
||||
children: [
|
||||
child,
|
||||
if (isInProgress)
|
||||
PopScope(
|
||||
canPop: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
const ModalBarrier(
|
||||
dismissible: false,
|
||||
color: Colors.black54,
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
if (progressMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
progressMessage,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
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