Compare commits
3 Commits
8cf0ea4e9a
...
main
| 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>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
|
||||||
<string>13.0</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
997C0E4FB7B2C67AB8388B3F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FB0CFA44E0F4A317CC3E8B41 /* Pods_RunnerTests.framework */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -80,7 +80,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
CAFC9CC8D4DE6A37AF21BCD7 /* Pods_Runner.framework in Frameworks */,
|
A8B379B8CE90BAD414DCE8D3 /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,12 +2,15 @@ import Flutter
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<!-- Kamera-Berechtigung -->
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<key>NSCameraUsageDescription</key>
|
<true/>
|
||||||
<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>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@ -33,10 +22,50 @@
|
|||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<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>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<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>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
@ -54,21 +83,5 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import 'component.dart';
|
||||||
|
|
||||||
part 'article.g.dart';
|
part 'article.g.dart';
|
||||||
|
|
||||||
@JsonSerializable(fieldRename: FieldRename.snake)
|
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||||
@ -15,6 +17,10 @@ class ArticleDTO {
|
|||||||
required this.scannedAmount,
|
required this.scannedAmount,
|
||||||
required this.removeNoteId,
|
required this.removeNoteId,
|
||||||
required this.taxRate,
|
required this.taxRate,
|
||||||
|
required this.isParent,
|
||||||
|
this.components,
|
||||||
|
this.warehouseNr,
|
||||||
|
this.warehouseName,
|
||||||
});
|
});
|
||||||
|
|
||||||
String name;
|
String name;
|
||||||
@ -27,6 +33,10 @@ class ArticleDTO {
|
|||||||
String scannedRemovedAmount;
|
String scannedRemovedAmount;
|
||||||
String? removeNoteId;
|
String? removeNoteId;
|
||||||
bool scannable;
|
bool scannable;
|
||||||
|
bool isParent;
|
||||||
|
List<ComponentDTO>? components;
|
||||||
|
String? warehouseNr;
|
||||||
|
String? warehouseName;
|
||||||
|
|
||||||
factory ArticleDTO.fromJson(Map<String, dynamic> json) =>
|
factory ArticleDTO.fromJson(Map<String, dynamic> json) =>
|
||||||
_$ArticleDTOFromJson(json);
|
_$ArticleDTOFromJson(json);
|
||||||
|
|||||||
@ -17,6 +17,13 @@ ArticleDTO _$ArticleDTOFromJson(Map<String, dynamic> json) => ArticleDTO(
|
|||||||
scannedAmount: json['scanned_amount'] as String,
|
scannedAmount: json['scanned_amount'] as String,
|
||||||
removeNoteId: json['remove_note_id'] as String?,
|
removeNoteId: json['remove_note_id'] as String?,
|
||||||
taxRate: json['tax_rate'] 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) =>
|
Map<String, dynamic> _$ArticleDTOToJson(ArticleDTO instance) =>
|
||||||
@ -31,4 +38,8 @@ Map<String, dynamic> _$ArticleDTOToJson(ArticleDTO instance) =>
|
|||||||
'scanned_removed_amount': instance.scannedRemovedAmount,
|
'scanned_removed_amount': instance.scannedRemovedAmount,
|
||||||
'remove_note_id': instance.removeNoteId,
|
'remove_note_id': instance.removeNoteId,
|
||||||
'scannable': instance.scannable,
|
'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)
|
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||||
class CustomerDTO {
|
class CustomerDTO {
|
||||||
CustomerDTO({required this.name, required this.address});
|
CustomerDTO({required this.name, required this.address, this.eMail});
|
||||||
|
|
||||||
String name;
|
String name;
|
||||||
AddressDTO address;
|
AddressDTO address;
|
||||||
|
String? eMail;
|
||||||
|
|
||||||
factory CustomerDTO.fromJson(Map<String, dynamic> json) => _$CustomerDTOFromJson(json);
|
factory CustomerDTO.fromJson(Map<String, dynamic> json) => _$CustomerDTOFromJson(json);
|
||||||
Map<dynamic, dynamic> toJson() => _$CustomerDTOToJson(this);
|
Map<dynamic, dynamic> toJson() => _$CustomerDTOToJson(this);
|
||||||
|
|||||||
@ -9,7 +9,12 @@ part of 'customer.dart';
|
|||||||
CustomerDTO _$CustomerDTOFromJson(Map<String, dynamic> json) => CustomerDTO(
|
CustomerDTO _$CustomerDTOFromJson(Map<String, dynamic> json) => CustomerDTO(
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
address: AddressDTO.fromJson(json['address'] as Map<String, dynamic>),
|
address: AddressDTO.fromJson(json['address'] as Map<String, dynamic>),
|
||||||
|
eMail: json['e_mail'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$CustomerDTOToJson(CustomerDTO instance) =>
|
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:hl_lieferservice/model/delivery.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
part 'delivery_update.g.dart';
|
part 'delivery_update.g.dart';
|
||||||
@ -74,7 +75,9 @@ class DeliveryUpdateDTO {
|
|||||||
carId: delivery.carId?.toString() ,
|
carId: delivery.carId?.toString() ,
|
||||||
selectedPaymentMethodId: delivery.payment.id,
|
selectedPaymentMethodId: delivery.payment.id,
|
||||||
options: delivery.options.map(DeliveryOptionUpdateDTO.fromEntity).toList(),
|
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()) {
|
: super(Unauthenticated()) {
|
||||||
on<SetAuthenticatedEvent>(_auth);
|
on<SetAuthenticatedEvent>(_auth);
|
||||||
on<Logout>(_logout);
|
on<Logout>(_logout);
|
||||||
|
on<SessionExpiredEvent>(_sessionExpired);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _auth(
|
Future<void> _auth(
|
||||||
SetAuthenticatedEvent event,
|
SetAuthenticatedEvent event,
|
||||||
Emitter<AuthState> emit,
|
Emitter<AuthState> emit,
|
||||||
) async {
|
) async {
|
||||||
operationBloc.add(LoadOperation());
|
|
||||||
await Future.delayed(Duration(seconds: 5));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint("Retrieve user information");
|
debugPrint("Retrieve user information");
|
||||||
|
|
||||||
|
emit(Authenticating());
|
||||||
var response = await service.getUserinfo(event.sessionId);
|
var response = await service.getUserinfo(event.sessionId);
|
||||||
var state = Authenticated(sessionId: event.sessionId, user: response);
|
var state = Authenticated(sessionId: event.sessionId, user: response);
|
||||||
locator.registerSingleton<Authenticated>(state);
|
locator.registerSingleton<Authenticated>(state);
|
||||||
emit(state);
|
emit(state);
|
||||||
operationBloc.add(FinishOperation());
|
|
||||||
} catch (err, st) {
|
} catch (err, st) {
|
||||||
debugPrint("Failed to retrieve user information");
|
debugPrint("Failed to retrieve user information");
|
||||||
debugPrint(err.toString());
|
debugPrint(err.toString());
|
||||||
debugPrint(st.toString());
|
debugPrint(st.toString());
|
||||||
|
|
||||||
|
emit(Unauthenticated());
|
||||||
operationBloc.add(
|
operationBloc.add(
|
||||||
FailOperation(
|
FailOperation(
|
||||||
message: "Login war nicht erfolgreich. Probieren Sie es erneut.",
|
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 {
|
Future<void> _logout(Logout event, Emitter<AuthState> emit) async {
|
||||||
|
if (locator.isRegistered<Authenticated>()) {
|
||||||
|
locator.unregister<Authenticated>();
|
||||||
|
}
|
||||||
emit(Unauthenticated());
|
emit(Unauthenticated());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _sessionExpired(
|
||||||
|
SessionExpiredEvent event,
|
||||||
|
Emitter<AuthState> emit,
|
||||||
|
) async {
|
||||||
|
if (locator.isRegistered<Authenticated>()) {
|
||||||
|
locator.unregister<Authenticated>();
|
||||||
|
}
|
||||||
|
emit(Unauthenticated(sessionExpired: true));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,4 +10,6 @@ class Logout extends AuthEvent {
|
|||||||
String username;
|
String username;
|
||||||
|
|
||||||
Logout({required this.username});
|
Logout({required this.username});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SessionExpiredEvent extends AuthEvent {}
|
||||||
@ -2,7 +2,15 @@ import 'package:hl_lieferservice/feature/authentication/model/user.dart';
|
|||||||
|
|
||||||
abstract class AuthState {}
|
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 {
|
class Authenticated extends AuthState {
|
||||||
User user;
|
User user;
|
||||||
String sessionId;
|
String sessionId;
|
||||||
|
|||||||
@ -18,7 +18,8 @@ class LoginEnforcer extends StatelessWidget {
|
|||||||
return child;
|
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:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/authentication/bloc/auth_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_event.dart';
|
||||||
|
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:hl_lieferservice/util.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
class LoginPage extends StatefulWidget {
|
class LoginPage extends StatefulWidget {
|
||||||
const LoginPage({super.key});
|
final bool sessionExpired;
|
||||||
|
|
||||||
|
const LoginPage({super.key, this.sessionExpired = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StatefulWidget> createState() => _LoginPageState();
|
State<StatefulWidget> createState() => _LoginPageState();
|
||||||
@ -58,9 +62,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
// Small delay to ensure listener is ready
|
// Small delay to ensure listener is ready
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
debugPrint("🔵 Opening browser to: http://localhost:3000/login");
|
final loginUrl = Uri.parse('${getConfig().backendUrl}/login');
|
||||||
|
|
||||||
final loginUrl = Uri.parse('http://100.72.100.33:3000/login');
|
|
||||||
final launched = await launchUrl(
|
final launched = await launchUrl(
|
||||||
loginUrl,
|
loginUrl,
|
||||||
mode: LaunchMode.externalApplication,
|
mode: LaunchMode.externalApplication,
|
||||||
@ -127,8 +129,22 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(),
|
appBar: AppBar(),
|
||||||
body: Center(
|
body: Column(
|
||||||
child: 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,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
@ -160,17 +176,30 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 15, bottom: 15),
|
padding: const EdgeInsets.only(top: 15, bottom: 15),
|
||||||
child: _isLoading
|
child: BlocBuilder<AuthBloc, AuthState>(
|
||||||
? const Column(
|
builder: (context, authState) {
|
||||||
children: [
|
final isBusy =
|
||||||
CircularProgressIndicator(),
|
_isLoading || authState is Authenticating;
|
||||||
SizedBox(height: 16),
|
if (!isBusy) {
|
||||||
Text('Warte auf Login...'),
|
return OutlinedButton(
|
||||||
],
|
onPressed: _onPressLogin,
|
||||||
)
|
child: const Text(
|
||||||
: OutlinedButton(
|
"Anmelden mit Holzleitner Login",
|
||||||
onPressed: _onPressLogin,
|
),
|
||||||
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...',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -179,6 +208,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: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/feature/cars/repository/cars_repository.dart';
|
||||||
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
|
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
|
||||||
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.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> {
|
class CarsBloc extends Bloc<CarEvents, CarsState> {
|
||||||
CarsRepository repository;
|
CarsRepository repository;
|
||||||
OperationBloc opBloc;
|
OperationBloc opBloc;
|
||||||
|
AuthBloc authBloc;
|
||||||
|
|
||||||
CarsBloc({required this.repository, required this.opBloc})
|
CarsBloc({required this.repository, required this.opBloc, required this.authBloc})
|
||||||
: super(CarsInitial()) {
|
: super(CarsInitial()) {
|
||||||
on<CarAdd>(_carAdd);
|
on<CarAdd>(_carAdd);
|
||||||
on<CarEdit>(_carEdit);
|
on<CarEdit>(_carEdit);
|
||||||
@ -19,12 +23,27 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
|
|||||||
on<CarLoad>(_carLoad);
|
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 {
|
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 {
|
try {
|
||||||
emit(CarsLoading());
|
emit(CarsLoading());
|
||||||
List<Car> cars = await repository.getAll(event.teamId);
|
List<Car> cars = await repository.getAll(event.teamId);
|
||||||
emit(CarsLoaded(cars: cars, teamId: event.teamId));
|
emit(CarsLoaded(cars: cars, teamId: event.teamId));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e is UserUnauthorized) {
|
||||||
|
authBloc.add(SessionExpiredEvent());
|
||||||
|
return;
|
||||||
|
}
|
||||||
emit(CarsLoadingFailed());
|
emit(CarsLoadingFailed());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -32,8 +51,8 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
|
|||||||
Future<void> _carAdd(CarAdd event, Emitter<CarsState> emit) async {
|
Future<void> _carAdd(CarAdd event, Emitter<CarsState> emit) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
|
|
||||||
|
opBloc.add(StartOperation());
|
||||||
try {
|
try {
|
||||||
opBloc.add(LoadOperation());
|
|
||||||
Car newCar = await repository.add(event.teamId, event.plate);
|
Car newCar = await repository.add(event.teamId, event.plate);
|
||||||
|
|
||||||
if (currentState is CarsLoaded) {
|
if (currentState is CarsLoaded) {
|
||||||
@ -46,15 +65,15 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
|
|||||||
|
|
||||||
opBloc.add(FinishOperation(message: "Auto erfolgreich hinzugefügt"));
|
opBloc.add(FinishOperation(message: "Auto erfolgreich hinzugefügt"));
|
||||||
} catch (e) {
|
} 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 {
|
Future<void> _carEdit(CarEdit event, Emitter<CarsState> emit) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
|
|
||||||
|
opBloc.add(StartOperation());
|
||||||
try {
|
try {
|
||||||
opBloc.add(LoadOperation());
|
|
||||||
await repository.edit(event.teamId, event.newCar);
|
await repository.edit(event.teamId, event.newCar);
|
||||||
|
|
||||||
if (currentState is CarsLoaded) {
|
if (currentState is CarsLoaded) {
|
||||||
@ -74,15 +93,15 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
|
|||||||
|
|
||||||
opBloc.add(FinishOperation(message: "Auto erfolgreich editiert"));
|
opBloc.add(FinishOperation(message: "Auto erfolgreich editiert"));
|
||||||
} catch (e) {
|
} 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 {
|
Future<void> _carDelete(CarDelete event, Emitter<CarsState> emit) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
|
|
||||||
|
opBloc.add(StartOperation());
|
||||||
try {
|
try {
|
||||||
opBloc.add(LoadOperation());
|
|
||||||
await repository.delete(event.carId, event.teamId);
|
await repository.delete(event.carId, event.teamId);
|
||||||
|
|
||||||
if (currentState is CarsLoaded) {
|
if (currentState is CarsLoaded) {
|
||||||
@ -100,7 +119,7 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
|
|||||||
|
|
||||||
opBloc.add(FinishOperation(message: "Auto erfolgreich gelöscht"));
|
opBloc.add(FinishOperation(message: "Auto erfolgreich gelöscht"));
|
||||||
} catch (e) {
|
} 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 {
|
class CarLoad extends CarEvents {
|
||||||
String teamId;
|
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 {
|
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 {
|
class CarCard extends StatelessWidget {
|
||||||
final Car car;
|
final Car car;
|
||||||
|
final bool isSelected;
|
||||||
final Function(Car car) onDelete;
|
final Function(Car car) onDelete;
|
||||||
final Function(Car car, String newName) onEdit;
|
final Function(Car car, String newName) onEdit;
|
||||||
|
|
||||||
@ -13,11 +14,20 @@ class CarCard extends StatelessWidget {
|
|||||||
required this.car,
|
required this.car,
|
||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
|
this.isSelected = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final primary = Theme.of(context).primaryColor;
|
||||||
return Card(
|
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(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
child: Row(
|
child: Row(
|
||||||
@ -30,13 +40,30 @@ class CarCard extends StatelessWidget {
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.local_shipping,
|
Icons.local_shipping,
|
||||||
size: 32,
|
size: 32,
|
||||||
color: Theme.of(context).primaryColor,
|
color: primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 10),
|
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 {
|
class CarManagementOverview extends StatefulWidget {
|
||||||
final List<Car> cars;
|
final List<Car> cars;
|
||||||
|
final int? selectedCarId;
|
||||||
final Function(String plate) onAdd;
|
final Function(String plate) onAdd;
|
||||||
final Function(String id) onDelete;
|
final Function(String id) onDelete;
|
||||||
final Function(String id, String plate) onEdit;
|
final Function(String id, String plate) onEdit;
|
||||||
|
final Future<void> Function() onRefresh;
|
||||||
|
|
||||||
const CarManagementOverview({
|
const CarManagementOverview({
|
||||||
super.key,
|
super.key,
|
||||||
@ -16,6 +18,8 @@ class CarManagementOverview extends StatefulWidget {
|
|||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onAdd,
|
required this.onAdd,
|
||||||
|
required this.onRefresh,
|
||||||
|
this.selectedCarId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -40,30 +44,14 @@ class _CarManagementOverviewState extends State<CarManagementOverview> {
|
|||||||
widget.onEdit(car.id.toString(), newName);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text("Fahrzeuge"),
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onSecondary,
|
||||||
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: _addCar,
|
onPressed: _addCar,
|
||||||
backgroundColor: Theme.of(context).primaryColor,
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
@ -72,7 +60,34 @@ class _CarManagementOverviewState extends State<CarManagementOverview> {
|
|||||||
color: Theme.of(context).colorScheme.onSecondary,
|
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:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/authentication/bloc/auth_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/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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/cars/bloc/cars_event.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/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) {
|
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(
|
context.read<CarsBloc>().add(
|
||||||
CarDelete(carId: id, teamId: _authState.user.number),
|
CarDelete(carId: id, teamId: _authState.user.number),
|
||||||
);
|
);
|
||||||
@ -68,11 +118,20 @@ class _CarManagementPageState extends State<CarManagementPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state is CarsLoaded) {
|
if (state is CarsLoaded) {
|
||||||
return CarManagementOverview(
|
return BlocBuilder<CarSelectBloc, CarSelectState>(
|
||||||
cars: state.cars,
|
builder: (context, selectState) {
|
||||||
onEdit: _edit,
|
final int? selectedCarId = selectState is CarSelectComplete
|
||||||
onAdd: _add,
|
? selectState.selectedCar.id
|
||||||
onDelete: _remove,
|
: 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/overview/service/reorder_service.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/util.dart';
|
import 'package:hl_lieferservice/feature/delivery/util.dart';
|
||||||
import 'package:hl_lieferservice/model/tour.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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
|
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
|
||||||
import 'package:rxdart/rxdart.dart';
|
import 'package:rxdart/rxdart.dart';
|
||||||
|
|
||||||
class TourBloc extends Bloc<TourEvent, TourState> {
|
class TourBloc extends Bloc<TourEvent, TourState> {
|
||||||
OperationBloc opBloc;
|
OperationBloc opBloc;
|
||||||
|
AuthBloc authBloc;
|
||||||
TourRepository tourRepository;
|
TourRepository tourRepository;
|
||||||
StreamSubscription? _combinedSubscription;
|
StreamSubscription? _combinedSubscription;
|
||||||
|
|
||||||
TourBloc({required this.opBloc, required this.tourRepository})
|
TourBloc({required this.opBloc, required this.authBloc, required this.tourRepository})
|
||||||
: super(TourInitial()) {
|
: super(TourInitial()) {
|
||||||
_combinedSubscription = CombineLatestStream.combine2(
|
_combinedSubscription = CombineLatestStream.combine2(
|
||||||
tourRepository.tour,
|
tourRepository.tour,
|
||||||
@ -39,6 +43,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
on<AssignCarEvent>(_assignCar);
|
on<AssignCarEvent>(_assignCar);
|
||||||
on<IncrementArticleScanAmount>(_increment);
|
on<IncrementArticleScanAmount>(_increment);
|
||||||
on<ScanArticleEvent>(_scan);
|
on<ScanArticleEvent>(_scan);
|
||||||
|
on<ScanComponentEvent>(_scanComponent);
|
||||||
on<HoldDeliveryEvent>(_holdDelivery);
|
on<HoldDeliveryEvent>(_holdDelivery);
|
||||||
on<CancelDeliveryEvent>(_cancelDelivery);
|
on<CancelDeliveryEvent>(_cancelDelivery);
|
||||||
on<ReactivateDeliveryEvent>(_reactivateDelivery);
|
on<ReactivateDeliveryEvent>(_reactivateDelivery);
|
||||||
@ -61,17 +66,24 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
@override
|
@override
|
||||||
Future<void> close() {
|
Future<void> close() {
|
||||||
_combinedSubscription?.cancel();
|
_combinedSubscription?.cancel();
|
||||||
|
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleError(Object e, String fallbackMessage) {
|
||||||
|
if (e is UserUnauthorized) {
|
||||||
|
authBloc.add(SessionExpiredEvent());
|
||||||
|
} else {
|
||||||
|
opBloc.add(FailOperation(message: fallbackMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _setArticleAmount(
|
void _setArticleAmount(
|
||||||
SetArticleAmountEvent event,
|
SetArticleAmountEvent event,
|
||||||
Emitter<TourState> emit,
|
Emitter<TourState> emit,
|
||||||
) async {
|
) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is TourLoaded) {
|
if (currentState is TourLoaded) {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
try {
|
try {
|
||||||
await tourRepository.setArticleAmount(
|
await tourRepository.setArticleAmount(
|
||||||
event.deliveryId,
|
event.deliveryId,
|
||||||
@ -79,15 +91,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
event.amount,
|
event.amount,
|
||||||
event.reason,
|
event.reason,
|
||||||
);
|
);
|
||||||
|
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
opBloc.add(
|
debugPrint("$e $st");
|
||||||
FailOperation(message: "Fehler beim Ändern der Menge des Artikels"),
|
_handleError(e, "Fehler beim Ändern der Menge des Artikels");
|
||||||
);
|
|
||||||
|
|
||||||
debugPrint("$e");
|
|
||||||
debugPrint("$st");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -126,35 +133,23 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
Emitter<TourState> emit,
|
Emitter<TourState> emit,
|
||||||
) async {
|
) async {
|
||||||
Map<String, double> distances = {};
|
Map<String, double> distances = {};
|
||||||
opBloc.add(LoadOperation());
|
|
||||||
|
|
||||||
emit(TourRequestingDistances(tour: event.tour, payments: event.payments));
|
|
||||||
|
|
||||||
for (final delivery in event.tour.deliveries) {
|
for (final delivery in event.tour.deliveries) {
|
||||||
try {
|
try {
|
||||||
distances[delivery.id] = await DistanceService.getDistanceByRoad(
|
distances[delivery.id] = await DistanceService.getDistanceByRoad(
|
||||||
delivery.customer.address.toString(),
|
delivery.customer.address.toString(),
|
||||||
);
|
);
|
||||||
} catch (e,st) {
|
} catch (e, st) {
|
||||||
debugPrint("Fehler beim Laden der Distanz: $e");
|
debugPrint("Fehler beim Laden der Distanz: $e");
|
||||||
debugPrint("$st");
|
debugPrint("$st");
|
||||||
|
|
||||||
// set the distance to none in order to handle the error case
|
|
||||||
// afterwards for that specific delivery
|
|
||||||
distances[delivery.id] = double.nan;
|
distances[delivery.id] = double.nan;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
opBloc.add(FinishOperation());
|
final currentState = state;
|
||||||
// If an error occurred, then the distances will be empty
|
if (currentState is TourLoaded) {
|
||||||
// If the distances are empty then they shouldn't be displayed
|
emit(currentState.copyWith(distances: distances));
|
||||||
add(
|
}
|
||||||
RequestSortingInformationEvent(
|
|
||||||
tour: event.tour,
|
|
||||||
payments: event.payments,
|
|
||||||
distances: distances,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _requestSortingInformation(
|
void _requestSortingInformation(
|
||||||
@ -215,9 +210,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
tour: event.tour,
|
tour: event.tour,
|
||||||
paymentOptions: event.payments,
|
paymentOptions: event.payments,
|
||||||
sortingInformation: container,
|
sortingInformation: container,
|
||||||
distances: event.distances,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
add(RequestDeliveryDistanceEvent(tour: event.tour));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updated(TourUpdated event, Emitter<TourState> emit) {
|
void _updated(TourUpdated event, Emitter<TourState> emit) {
|
||||||
@ -233,14 +229,14 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
paymentOptions: payments,
|
paymentOptions: payments,
|
||||||
distances: Map<String, double>.from(currentState.distances ?? {}),
|
distances: Map<String, double>.from(currentState.distances ?? {}),
|
||||||
sortingInformation: currentState.sortingInformation,
|
sortingInformation: currentState.sortingInformation,
|
||||||
|
pendingScanRequests: currentState.pendingScanRequests,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download distances if tour has previously fetched by API
|
|
||||||
if (currentState is TourLoading) {
|
if (currentState is TourLoading) {
|
||||||
add(
|
add(
|
||||||
RequestDeliveryDistanceEvent(tour: tour.copyWith(), payments: payments),
|
RequestSortingInformationEvent(tour: tour.copyWith(), payments: payments),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -251,17 +247,13 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
) async {
|
) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is TourLoaded) {
|
if (currentState is TourLoaded) {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
try {
|
try {
|
||||||
await tourRepository.reactivateDelivery(event.deliveryId);
|
await tourRepository.reactivateDelivery(event.deliveryId);
|
||||||
|
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint("$e");
|
debugPrint("$e $st");
|
||||||
debugPrint("$st");
|
_handleError(e, "Fehler beim Zurückstellen der Lieferung");
|
||||||
opBloc.add(
|
|
||||||
FailOperation(message: "Fehler beim Zurückstellen der Lieferung"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -269,17 +261,13 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
void _holdDelivery(HoldDeliveryEvent event, Emitter<TourState> emit) async {
|
void _holdDelivery(HoldDeliveryEvent event, Emitter<TourState> emit) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is TourLoaded) {
|
if (currentState is TourLoaded) {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
try {
|
try {
|
||||||
await tourRepository.holdDelivery(event.deliveryId);
|
await tourRepository.holdDelivery(event.deliveryId);
|
||||||
|
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint("$e");
|
debugPrint("$e $st");
|
||||||
debugPrint("$st");
|
_handleError(e, "Fehler beim Zurückstellen der Lieferung");
|
||||||
opBloc.add(
|
|
||||||
FailOperation(message: "Fehler beim Zurückstellen der Lieferung"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -290,26 +278,69 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
) async {
|
) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is TourLoaded) {
|
if (currentState is TourLoaded) {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
try {
|
try {
|
||||||
await tourRepository.cancelDelivery(event.deliveryId);
|
await tourRepository.cancelDelivery(event.deliveryId);
|
||||||
|
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint("$e");
|
debugPrint("$e $st");
|
||||||
debugPrint("$st");
|
_handleError(e, "Fehler beim Stornieren der Lieferung");
|
||||||
opBloc.add(
|
}
|
||||||
FailOperation(message: "Fehler beim Zurückstellen 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: '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 {
|
void _scan(ScanArticleEvent event, Emitter<TourState> emit) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
opBloc.add(LoadOperation());
|
|
||||||
|
|
||||||
if (currentState is TourLoaded) {
|
if (currentState is TourLoaded) {
|
||||||
|
_bumpPendingScans(emit, 1);
|
||||||
try {
|
try {
|
||||||
switch (await tourRepository.scanArticle(
|
switch (await tourRepository.scanArticle(
|
||||||
event.deliveryId,
|
event.deliveryId,
|
||||||
@ -333,9 +364,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint("FEHLER beim Scannen eines Artikels: $e");
|
debugPrint("FEHLER beim Scannen eines Artikels: $e $st");
|
||||||
debugPrint("$st");
|
_handleError(e, "Fehler beim Scannen des Artikels");
|
||||||
opBloc.add(FailOperation(message: "Fehler beim Scannen des Artikels"));
|
} finally {
|
||||||
|
_bumpPendingScans(emit, -1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -347,17 +379,18 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
final currentState = state;
|
final currentState = state;
|
||||||
|
|
||||||
if (currentState is TourLoaded) {
|
if (currentState is TourLoaded) {
|
||||||
opBloc.add(LoadOperation());
|
_bumpPendingScans(emit, 1);
|
||||||
try {
|
try {
|
||||||
await tourRepository.scanArticle(
|
await tourRepository.scanArticle(
|
||||||
event.deliveryId,
|
event.deliveryId,
|
||||||
event.carId,
|
event.carId,
|
||||||
event.internalArticleId,
|
event.internalArticleId,
|
||||||
);
|
);
|
||||||
opBloc.add(FinishOperation());
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint(st.toString());
|
debugPrint("$e $st");
|
||||||
opBloc.add(FailOperation(message: "Fehler beim Scannen des Artikels"));
|
_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 {
|
Future<void> _assignCar(AssignCarEvent event, Emitter<TourState> emit) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is TourLoaded) {
|
if (currentState is TourLoaded) {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
try {
|
try {
|
||||||
await tourRepository.assignCar(event.deliveryId, event.carId);
|
await tourRepository.assignCar(event.deliveryId, event.carId);
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint(st.toString());
|
debugPrint("$e $st");
|
||||||
opBloc.add(
|
_handleError(e, "Fehler beim Zuweisen des Fahrzeugs");
|
||||||
FailOperation(message: "Fehler beim Zuweisen des Fahrzeugs"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load(LoadTour event, Emitter<TourState> emit) async {
|
Future<void> _load(LoadTour event, Emitter<TourState> emit) async {
|
||||||
opBloc.add(LoadOperation());
|
|
||||||
try {
|
try {
|
||||||
emit(TourLoading());
|
emit(TourLoading());
|
||||||
await tourRepository.loadTourOfToday(event.teamId);
|
await tourRepository.loadTourOfToday(event.teamId);
|
||||||
await tourRepository.loadPaymentOptions();
|
await tourRepository.loadPaymentOptions();
|
||||||
|
|
||||||
opBloc.add(FinishOperation());
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// go to the error state in order to give the user the chance
|
if (e is UserUnauthorized) {
|
||||||
// to reload if necessary.
|
authBloc.add(SessionExpiredEvent());
|
||||||
|
return;
|
||||||
|
}
|
||||||
emit(TourLoadingFailed());
|
emit(TourLoadingFailed());
|
||||||
opBloc.add(
|
opBloc.add(FailOperation(message: "Fehler beim Laden der heutigen Fahrten"));
|
||||||
FailOperation(message: "Fehler beim Laden der heutigen Fahrten"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -401,9 +429,9 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
Emitter<TourState> emit,
|
Emitter<TourState> emit,
|
||||||
) async {
|
) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
opBloc.add(LoadOperation());
|
|
||||||
|
|
||||||
if (currentState is TourLoaded) {
|
if (currentState is TourLoaded) {
|
||||||
|
opBloc.add(StartOperation(message: "Lieferung wird abgeschlossen…"));
|
||||||
try {
|
try {
|
||||||
await tourRepository.uploadDriverSignature(
|
await tourRepository.uploadDriverSignature(
|
||||||
event.deliveryId,
|
event.deliveryId,
|
||||||
@ -415,11 +443,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await tourRepository.finishDelivery(event.deliveryId);
|
await tourRepository.finishDelivery(event.deliveryId);
|
||||||
|
opBloc.add(FinishOperation(message: "Lieferung abgeschlossen"));
|
||||||
opBloc.add(FinishOperation());
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
opBloc.add(FailOperation(message: "Failed to update delivery"));
|
debugPrint("$e $st");
|
||||||
debugPrint(st.toString());
|
_handleError(e, "Fehler beim Abschließen der Lieferung");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -428,15 +455,13 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
UpdateSelectedPaymentMethodEvent event,
|
UpdateSelectedPaymentMethodEvent event,
|
||||||
Emitter<TourState> emit,
|
Emitter<TourState> emit,
|
||||||
) async {
|
) async {
|
||||||
|
opBloc.add(StartOperation());
|
||||||
try {
|
try {
|
||||||
opBloc.add(LoadOperation());
|
|
||||||
await tourRepository.updatePayment(event.deliveryId, event.payment);
|
await tourRepository.updatePayment(event.deliveryId, event.payment);
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint(st.toString());
|
debugPrint("$e $st");
|
||||||
opBloc.add(
|
_handleError(e, "Fehler beim Aktualisieren des Betrags");
|
||||||
FailOperation(message: "Fehler beim Aktualisieren des Betrags"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -444,8 +469,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
UpdateDeliveryOptionEvent event,
|
UpdateDeliveryOptionEvent event,
|
||||||
Emitter<TourState> emit,
|
Emitter<TourState> emit,
|
||||||
) async {
|
) async {
|
||||||
|
opBloc.add(StartOperation());
|
||||||
try {
|
try {
|
||||||
opBloc.add(LoadOperation());
|
|
||||||
await tourRepository.updateOption(
|
await tourRepository.updateOption(
|
||||||
event.deliveryId,
|
event.deliveryId,
|
||||||
event.key,
|
event.key,
|
||||||
@ -453,10 +478,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
);
|
);
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint("$st");
|
debugPrint("$e $st");
|
||||||
opBloc.add(
|
_handleError(e, "Fehler beim Aktualisieren der Optionen");
|
||||||
FailOperation(message: "Fehler beim Aktualisieren der Optionen"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -464,10 +487,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
UpdateDiscountEvent event,
|
UpdateDiscountEvent event,
|
||||||
Emitter<TourState> emit,
|
Emitter<TourState> emit,
|
||||||
) async {
|
) async {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
opBloc.add(FinishOperation());
|
|
||||||
await tourRepository.updateDiscount(
|
await tourRepository.updateDiscount(
|
||||||
event.deliveryId,
|
event.deliveryId,
|
||||||
event.reason,
|
event.reason,
|
||||||
@ -475,15 +496,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
);
|
);
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint(
|
debugPrint("Fehler beim Aktualisieren des Discounts: $e $st");
|
||||||
"Fehler beim Hinzufügen eins Discounts zur Lieferung: ${event.deliveryId}:",
|
_handleError(e, "Fehler beim Aktualisieren des Discounts");
|
||||||
);
|
|
||||||
debugPrint("$e");
|
|
||||||
debugPrint("$st");
|
|
||||||
|
|
||||||
opBloc.add(
|
|
||||||
FailOperation(message: "Fehler beim Hinzufügen des Discounts: $e"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -491,51 +505,33 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
RemoveDiscountEvent event,
|
RemoveDiscountEvent event,
|
||||||
Emitter<TourState> emit,
|
Emitter<TourState> emit,
|
||||||
) async {
|
) async {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tourRepository.removeDiscount(event.deliveryId);
|
await tourRepository.removeDiscount(event.deliveryId);
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint(
|
debugPrint("Fehler beim Löschen des Discounts: $e $st");
|
||||||
"Fehler beim Löschen des Discounts der Lieferung: ${event.deliveryId}:",
|
_handleError(e, "Fehler beim Löschen des Discounts");
|
||||||
);
|
|
||||||
debugPrint("$e");
|
|
||||||
debugPrint("$st");
|
|
||||||
|
|
||||||
opBloc.add(
|
|
||||||
FailOperation(message: "Fehler beim Löschen des Discounts: $e"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _addDiscount(AddDiscountEvent event, Emitter<TourState> emit) async {
|
void _addDiscount(AddDiscountEvent event, Emitter<TourState> emit) async {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tourRepository.addDiscount(
|
await tourRepository.addDiscount(
|
||||||
event.deliveryId,
|
event.deliveryId,
|
||||||
event.reason,
|
event.reason,
|
||||||
event.value,
|
event.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint(
|
debugPrint("Fehler beim Hinzufügen des Discounts: $e $st");
|
||||||
"Fehler beim Hinzufügen eins Discounts zur Lieferung: ${event.deliveryId}:",
|
_handleError(e, "Fehler beim Hinzufügen des Discounts");
|
||||||
);
|
|
||||||
debugPrint("$e");
|
|
||||||
debugPrint("$st");
|
|
||||||
|
|
||||||
opBloc.add(
|
|
||||||
FailOperation(message: "Fehler beim Hinzufügen des Discounts: $e"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _unscan(UnscanArticleEvent event, Emitter<TourState> emit) async {
|
void _unscan(UnscanArticleEvent event, Emitter<TourState> emit) async {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tourRepository.unscan(
|
await tourRepository.unscan(
|
||||||
event.deliveryId,
|
event.deliveryId,
|
||||||
@ -545,27 +541,19 @@ class TourBloc extends Bloc<TourEvent, TourState> {
|
|||||||
);
|
);
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint("Fehler beim Unscan des Artikels: ${event.articleId}:");
|
debugPrint("Fehler beim Unscan des Artikels ${event.articleId}: $e $st");
|
||||||
debugPrint("$e");
|
_handleError(e, "Fehler beim Unscan des Artikels");
|
||||||
debugPrint("$st");
|
|
||||||
|
|
||||||
opBloc.add(FailOperation(message: "Fehler beim Unscan des Artikels: $e"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resetAmount(ResetScanAmountEvent event, Emitter<TourState> emit) async {
|
void _resetAmount(ResetScanAmountEvent event, Emitter<TourState> emit) async {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tourRepository.resetScan(event.articleId, event.deliveryId);
|
await tourRepository.resetScan(event.articleId, event.deliveryId);
|
||||||
|
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint("Fehler beim Unscan des Artikels: ${event.articleId}:");
|
debugPrint("Fehler beim Zurücksetzen Artikel ${event.articleId}: $e $st");
|
||||||
debugPrint("$e");
|
_handleError(e, "Fehler beim Zurücksetzen");
|
||||||
debugPrint("$st");
|
|
||||||
|
|
||||||
opBloc.add(FailOperation(message: "Fehler beim Zurücksetzen: $e"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,20 +15,17 @@ class LoadTour extends TourEvent {
|
|||||||
|
|
||||||
class RequestDeliveryDistanceEvent extends TourEvent {
|
class RequestDeliveryDistanceEvent extends TourEvent {
|
||||||
Tour tour;
|
Tour tour;
|
||||||
List<Payment> payments;
|
|
||||||
|
|
||||||
RequestDeliveryDistanceEvent({required this.tour, required this.payments});
|
RequestDeliveryDistanceEvent({required this.tour});
|
||||||
}
|
}
|
||||||
|
|
||||||
class RequestSortingInformationEvent extends TourEvent {
|
class RequestSortingInformationEvent extends TourEvent {
|
||||||
Tour tour;
|
Tour tour;
|
||||||
List<Payment> payments;
|
List<Payment> payments;
|
||||||
Map<String, double>? distances;
|
|
||||||
|
|
||||||
RequestSortingInformationEvent({
|
RequestSortingInformationEvent({
|
||||||
required this.tour,
|
required this.tour,
|
||||||
required this.payments,
|
required this.payments,
|
||||||
this.distances,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,6 +87,20 @@ class ScanArticleEvent extends TourEvent {
|
|||||||
String carId;
|
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 {
|
class CancelDeliveryEvent extends TourEvent {
|
||||||
String deliveryId;
|
String deliveryId;
|
||||||
|
|
||||||
|
|||||||
@ -8,49 +8,38 @@ class TourLoading extends TourState {}
|
|||||||
|
|
||||||
class TourLoadingFailed 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 {
|
class TourLoaded extends TourState {
|
||||||
Tour tour;
|
Tour tour;
|
||||||
Map<String, double>? distances;
|
Map<String, double>? distances;
|
||||||
List<Payment> paymentOptions;
|
List<Payment> paymentOptions;
|
||||||
Map<String, List<String>> sortingInformation;
|
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({
|
TourLoaded({
|
||||||
required this.tour,
|
required this.tour,
|
||||||
this.distances,
|
this.distances,
|
||||||
required this.paymentOptions,
|
required this.paymentOptions,
|
||||||
required this.sortingInformation
|
required this.sortingInformation,
|
||||||
|
this.pendingScanRequests = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
TourLoaded copyWith({
|
TourLoaded copyWith({
|
||||||
Tour? tour,
|
Tour? tour,
|
||||||
Map<String, double>? distances,
|
Map<String, double>? distances,
|
||||||
List<Payment>? paymentOptions,
|
List<Payment>? paymentOptions,
|
||||||
Map<String, List<String>>? sortingInformation
|
Map<String, List<String>>? sortingInformation,
|
||||||
|
int? pendingScanRequests,
|
||||||
}) {
|
}) {
|
||||||
return TourLoaded(
|
return TourLoaded(
|
||||||
tour: tour ?? this.tour,
|
tour: tour ?? this.tour,
|
||||||
distances: distances ?? this.distances,
|
distances: distances ?? this.distances,
|
||||||
paymentOptions: paymentOptions ?? this.paymentOptions,
|
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/cupertino.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
|
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
|
||||||
import 'package:rxdart/rxdart.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> {
|
class NoteBloc extends Bloc<NoteEvent, NoteState> {
|
||||||
final NoteRepository repository;
|
final NoteRepository repository;
|
||||||
final OperationBloc opBloc;
|
final OperationBloc opBloc;
|
||||||
|
final AuthBloc authBloc;
|
||||||
final String deliveryId;
|
final String deliveryId;
|
||||||
|
|
||||||
StreamSubscription? _combinedSubscription;
|
StreamSubscription? _combinedSubscription;
|
||||||
@ -22,6 +26,7 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
|
|||||||
NoteBloc({
|
NoteBloc({
|
||||||
required this.repository,
|
required this.repository,
|
||||||
required this.opBloc,
|
required this.opBloc,
|
||||||
|
required this.authBloc,
|
||||||
required this.deliveryId,
|
required this.deliveryId,
|
||||||
}) : super(NoteInitial()) {
|
}) : super(NoteInitial()) {
|
||||||
_combinedSubscription = CombineLatestStream.combine3(
|
_combinedSubscription = CombineLatestStream.combine3(
|
||||||
@ -60,10 +65,17 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
|
|||||||
@override
|
@override
|
||||||
Future<void> close() {
|
Future<void> close() {
|
||||||
_combinedSubscription?.cancel();
|
_combinedSubscription?.cancel();
|
||||||
|
|
||||||
return super.close();
|
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 {
|
Future<void> _dataUpdated(DataUpdated event, Emitter<NoteState> emit) async {
|
||||||
emit(
|
emit(
|
||||||
NoteLoaded(
|
NoteLoaded(
|
||||||
@ -82,96 +94,79 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
|
|||||||
RemoveImageNote event,
|
RemoveImageNote event,
|
||||||
Emitter<NoteState> emit,
|
Emitter<NoteState> emit,
|
||||||
) async {
|
) async {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await repository.deleteImage(event.deliveryId, event.objectId);
|
await repository.deleteImage(event.deliveryId, event.objectId);
|
||||||
|
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint("Fehler beim Löschen des Bildes: $e");
|
debugPrint("Fehler beim Löschen des Bildes: $e $st");
|
||||||
debugPrint(st.toString());
|
_handleError(e, "Fehler beim Löschen des Bildes");
|
||||||
|
|
||||||
opBloc.add(FailOperation(message: e.toString()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _upload(AddImageNote event, Emitter<NoteState> emit) async {
|
Future<void> _upload(AddImageNote event, Emitter<NoteState> emit) async {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Uint8List imageBytes = await event.file.readAsBytes();
|
Uint8List imageBytes = await event.file.readAsBytes();
|
||||||
await repository.addImage(event.deliveryId, imageBytes);
|
await repository.addImage(event.deliveryId, imageBytes);
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint("Fehler beim Hinzufügen des Bildes: $e");
|
debugPrint("Fehler beim Hinzufügen des Bildes: $e $st");
|
||||||
debugPrint(st.toString());
|
_handleError(e, "Fehler beim Hinzufügen des Bildes");
|
||||||
|
|
||||||
opBloc.add(FailOperation(message: e.toString()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load(LoadNote event, Emitter<NoteState> emit) async {
|
Future<void> _load(LoadNote event, Emitter<NoteState> emit) async {
|
||||||
|
if (state is NoteLoaded || state is NoteLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
emit.call(NoteLoading());
|
emit.call(NoteLoading());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await repository.loadNotes(event.delivery.id);
|
await repository.loadNotes(event.delivery.id);
|
||||||
await repository.loadTemplates();
|
await repository.loadTemplates();
|
||||||
|
|
||||||
opBloc.add(FinishOperation());
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint("Fehler beim Herunterladen der Notizen: $e");
|
debugPrint("Fehler beim Herunterladen der Notizen: $e $st");
|
||||||
debugPrint(st.toString());
|
if (e is UserUnauthorized) {
|
||||||
|
authBloc.add(SessionExpiredEvent());
|
||||||
opBloc.add(
|
return;
|
||||||
FailOperation(message: "Notizen konnten nicht heruntergeladen werden."),
|
}
|
||||||
);
|
opBloc.add(FailOperation(message: "Notizen konnten nicht heruntergeladen werden."));
|
||||||
|
|
||||||
emit.call(NoteLoadingFailed());
|
emit.call(NoteLoadingFailed());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _add(AddNote event, Emitter<NoteState> emit) async {
|
Future<void> _add(AddNote event, Emitter<NoteState> emit) async {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await repository.addNote(event.deliveryId, event.note);
|
await repository.addNote(event.deliveryId, event.note);
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint("Fehler beim Hinzufügen der Notiz: $e");
|
debugPrint("Fehler beim Hinzufügen der Notiz: $e $st");
|
||||||
debugPrint(st.toString());
|
_handleError(e, "Fehler beim Hinzufügen der Notiz");
|
||||||
|
|
||||||
opBloc.add(FailOperation(message: e.toString()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _edit(EditNote event, Emitter<NoteState> emit) async {
|
Future<void> _edit(EditNote event, Emitter<NoteState> emit) async {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await repository.editNote(event.noteId, event.content);
|
await repository.editNote(event.noteId, event.content);
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint("Fehler beim Hinzufügen der Notiz: $e");
|
debugPrint("Fehler beim Editieren der Notiz: $e $st");
|
||||||
debugPrint(st.toString());
|
_handleError(e, "Fehler beim Editieren der Notiz");
|
||||||
|
|
||||||
opBloc.add(FailOperation(message: e.toString()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _remove(RemoveNote event, Emitter<NoteState> emit) async {
|
Future<void> _remove(RemoveNote event, Emitter<NoteState> emit) async {
|
||||||
opBloc.add(LoadOperation());
|
opBloc.add(StartOperation());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await repository.deleteNote(event.noteId);
|
await repository.deleteNote(event.noteId);
|
||||||
opBloc.add(FinishOperation());
|
opBloc.add(FinishOperation());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint("Fehler beim Hinzufügen der Notiz: $e");
|
debugPrint("Fehler beim Löschen der Notiz: $e $st");
|
||||||
debugPrint(st.toString());
|
_handleError(e, "Notiz konnte nicht gelöscht werden");
|
||||||
|
|
||||||
opBloc.add(
|
|
||||||
FailOperation(message: "Notizen konnte nicht gelöscht werden."),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -91,8 +91,10 @@ class _ResetArticleAmountDialogState extends State<ResetArticleAmountDialog> {
|
|||||||
children: [
|
children: [
|
||||||
const Text("Wollen Sie die entfernten Artikel wieder hinzufügen?"),
|
const Text("Wollen Sie die entfernten Artikel wieder hinzufügen?"),
|
||||||
!widget.article.scannable ? _amountSelection() : Container(),
|
!widget.article.scannable ? _amountSelection() : Container(),
|
||||||
Row(
|
Wrap(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
spacing: 10,
|
||||||
|
runSpacing: 8,
|
||||||
|
alignment: WrapAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _reset,
|
onPressed: _reset,
|
||||||
|
|||||||
@ -154,8 +154,10 @@ class _ArticleUnscanDialogState extends State<ArticleUnscanDialog> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
Wrap(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
spacing: 10,
|
||||||
|
runSpacing: 8,
|
||||||
|
alignment: WrapAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: isValidText ? _unscan : null,
|
onPressed: isValidText ? _unscan : null,
|
||||||
|
|||||||
@ -142,66 +142,70 @@ class _DeliveryDetailState extends State<DeliveryDetail> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _stepsNavigation(Delivery delivery) {
|
Widget _stepsNavigation(Delivery delivery) {
|
||||||
return SizedBox(
|
return SafeArea(
|
||||||
width: double.infinity,
|
top: false,
|
||||||
height: 90,
|
child: SizedBox(
|
||||||
child: Row(
|
width: double.infinity,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
height: 90,
|
||||||
children: [
|
child: Row(
|
||||||
OutlinedButton(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
onPressed: _step == 0 ? null : _clickBack,
|
children: [
|
||||||
child: const Text("zurück"),
|
OutlinedButton(
|
||||||
),
|
onPressed: _step == 0 ? null : _clickBack,
|
||||||
Padding(
|
child: const Text("zurück"),
|
||||||
padding: const EdgeInsets.only(left: 20),
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: () {
|
|
||||||
if (_step == _steps.length - 1) {
|
|
||||||
_openSignatureView(delivery);
|
|
||||||
} else {
|
|
||||||
_clickForward();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child:
|
|
||||||
_step == _steps.length - 1
|
|
||||||
? const Text("Unterschreiben")
|
|
||||||
: const Text("weiter"),
|
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
],
|
padding: const EdgeInsets.only(left: 20),
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (_step == _steps.length - 1) {
|
||||||
|
_openSignatureView(delivery);
|
||||||
|
} else {
|
||||||
|
_clickForward();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
_step == _steps.length - 1
|
||||||
|
? const Text("Unterschreiben")
|
||||||
|
: const Text("weiter"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return BlocBuilder<TourBloc, TourState>(
|
||||||
appBar: AppBar(title: const Text("Auslieferungsdetails")),
|
builder: (context, state) {
|
||||||
body: BlocBuilder<TourBloc, TourState>(
|
Delivery? delivery;
|
||||||
builder: (context, state) {
|
if (state is TourLoaded) {
|
||||||
final currentState = state;
|
delivery = state.tour.deliveries.firstWhere(
|
||||||
|
(d) => d.id == widget.deliveryId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (currentState is TourLoaded) {
|
return Scaffold(
|
||||||
Delivery delivery = currentState.tour.deliveries.firstWhere(
|
appBar: AppBar(title: const Text("Auslieferungsdetails")),
|
||||||
(delivery) => delivery.id == widget.deliveryId,
|
body: delivery == null
|
||||||
);
|
? const Center(child: CircularProgressIndicator())
|
||||||
return Column(
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
_stepInfo(),
|
_stepInfo(),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child:
|
child:
|
||||||
StepFactory().make(_step, delivery) ??
|
StepFactory().make(_step, delivery) ??
|
||||||
_stepMissingWarning(),
|
_stepMissingWarning(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_stepsNavigation(delivery),
|
bottomNavigationBar:
|
||||||
],
|
delivery == null ? null : _stepsNavigation(delivery),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -195,17 +195,16 @@ class _DeliveryDiscountState extends State<DeliveryDiscount> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
FilledButton(
|
||||||
padding: const EdgeInsets.only(right: 10),
|
onPressed:
|
||||||
child: FilledButton(
|
!_isReasonEmpty && _discountValue > 0
|
||||||
onPressed:
|
? _updateValues
|
||||||
!_isReasonEmpty && _discountValue > 0
|
: null,
|
||||||
? _updateValues
|
child: const Text("Speichern"),
|
||||||
: null,
|
|
||||||
child: const Text("Speichern"),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: _discountValue > 0 && widget.discount != null ? _resetValues : null,
|
onPressed: _discountValue > 0 && widget.discount != null ? _resetValues : null,
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import 'package:hl_lieferservice/model/delivery.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:signature/signature.dart';
|
import 'package:signature/signature.dart';
|
||||||
|
|
||||||
|
enum _SigningPhase { customerAcceptance, customerSignature, driverSignature }
|
||||||
|
|
||||||
class SignatureView extends StatefulWidget {
|
class SignatureView extends StatefulWidget {
|
||||||
const SignatureView({
|
const SignatureView({
|
||||||
super.key,
|
super.key,
|
||||||
@ -43,33 +45,11 @@ class _SignatureViewState extends State<SignatureView> {
|
|||||||
exportBackgroundColor: Colors.white,
|
exportBackgroundColor: Colors.white,
|
||||||
);
|
);
|
||||||
|
|
||||||
bool _isDriverSigning = false;
|
_SigningPhase _phase = _SigningPhase.customerAcceptance;
|
||||||
bool _customerAccepted = false;
|
|
||||||
bool _noteAccepted = false;
|
|
||||||
bool _notesEmpty = true;
|
|
||||||
bool _isCustomerSignatureEmpty = true;
|
|
||||||
bool _isDriverSignatureEmpty = true;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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));
|
context.read<NoteBloc>().add(LoadNote(delivery: widget.delivery));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,14 +60,88 @@ class _SignatureViewState extends State<SignatureView> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _signatureField() {
|
void _onAcceptanceDone() {
|
||||||
return Signature(
|
setState(() => _phase = _SigningPhase.customerSignature);
|
||||||
controller: _isDriverSigning ? _driverController : _customerController,
|
}
|
||||||
backgroundColor: Colors.white,
|
|
||||||
|
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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -98,163 +152,171 @@ class _SignatureViewState extends State<SignatureView> {
|
|||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BlocConsumer<NoteBloc, NoteState>(
|
_notesContent(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()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
const Padding(padding: EdgeInsets.only(top: 25), child: Divider()),
|
const Padding(padding: EdgeInsets.only(top: 25), child: Divider()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _customerCheckboxes() {
|
@override
|
||||||
return !_isDriverSigning
|
Widget build(BuildContext context) {
|
||||||
? Column(
|
return BlocBuilder<NoteBloc, NoteState>(
|
||||||
children: [
|
builder: (context, noteState) {
|
||||||
Padding(
|
final notesEmpty = switch (noteState) {
|
||||||
padding: const EdgeInsets.only(top: 25, bottom: 0),
|
NoteLoadedBase(notes: final ns) => ns.isEmpty,
|
||||||
child: _notes(),
|
_ => true,
|
||||||
),
|
};
|
||||||
Padding(
|
final isButtonEnabled =
|
||||||
padding: const EdgeInsets.only(top: 25.0, bottom: 0),
|
_customerAccepted && (_noteAccepted || notesEmpty);
|
||||||
child: Row(
|
|
||||||
children: [
|
return Scaffold(
|
||||||
Checkbox(
|
appBar: AppBar(title: const Text("Unterschrift des Kunden")),
|
||||||
value: _noteAccepted,
|
body: Padding(
|
||||||
onChanged:
|
padding: const EdgeInsets.all(20.0),
|
||||||
_notesEmpty
|
child: ListView(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 25, bottom: 0),
|
||||||
|
child: _notes(noteState),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 25.0, bottom: 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: _noteAccepted,
|
||||||
|
onChanged: notesEmpty
|
||||||
? null
|
? null
|
||||||
: (value) {
|
: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_noteAccepted = value!;
|
_noteAccepted = value!;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
|
||||||
Flexible(
|
|
||||||
child: InkWell(
|
|
||||||
onTap: _notesEmpty ? null : () {
|
|
||||||
setState(() {
|
|
||||||
_noteAccepted = !_noteAccepted;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: Text(
|
|
||||||
"Ich nehme die oben genannten Anmerkungen zur Lieferung zur Kenntnis.",
|
|
||||||
overflow: TextOverflow.fade,
|
|
||||||
),
|
),
|
||||||
),
|
Flexible(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: notesEmpty
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
setState(() {
|
||||||
|
_noteAccepted = !_noteAccepted;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
"Ich nehme die oben genannten Anmerkungen zur Lieferung zur Kenntnis.",
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 25.0, bottom: 10.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: _customerAccepted,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_customerAccepted = value!;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: InkWell(
|
||||||
|
child: Text(
|
||||||
|
"Ware in ordnungsgemäßem Zustand erhalten. Aufstell- und Einbauarbeiten wurden korrekt durchgeführt",
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_customerAccepted = !_customerAccepted;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 90,
|
||||||
|
child: Center(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: isButtonEnabled ? widget.onContinue : null,
|
||||||
|
child: const Text("Unterschreiben"),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.only(top: 25.0, bottom: 10.0),
|
);
|
||||||
child: Row(
|
},
|
||||||
children: [
|
);
|
||||||
Checkbox(
|
}
|
||||||
value: _customerAccepted,
|
}
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
class _SignaturePadStep extends StatefulWidget {
|
||||||
_customerAccepted = value!;
|
const _SignaturePadStep({
|
||||||
});
|
required this.controller,
|
||||||
},
|
required this.delivery,
|
||||||
),
|
required this.appBarTitle,
|
||||||
Flexible(
|
required this.buttonLabel,
|
||||||
child: InkWell(
|
required this.onContinue,
|
||||||
child: Text(
|
});
|
||||||
"Ware in ordnungsgemäßem Zustand erhalten. Aufstell- und Einbauarbeiten wurden korrekt durchgeführt",
|
|
||||||
overflow: TextOverflow.fade,
|
final SignatureController controller;
|
||||||
),
|
final Delivery delivery;
|
||||||
onTap: () {
|
final String appBarTitle;
|
||||||
setState(() {
|
final String buttonLabel;
|
||||||
_customerAccepted = !_customerAccepted;
|
final VoidCallback onContinue;
|
||||||
});
|
|
||||||
},
|
@override
|
||||||
),
|
State<_SignaturePadStep> createState() => _SignaturePadStepState();
|
||||||
),
|
}
|
||||||
],
|
|
||||||
),
|
class _SignaturePadStepState extends State<_SignaturePadStep> {
|
||||||
),
|
bool _isEmpty = true;
|
||||||
],
|
late final VoidCallback _listener;
|
||||||
)
|
|
||||||
: Container();
|
@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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
String formattedDate = DateFormat("dd.MM.yyyy").format(DateTime.now());
|
final formattedDate = DateFormat("dd.MM.yyyy").format(DateTime.now());
|
||||||
|
|
||||||
bool isButtonEnabled;
|
|
||||||
if (!_isDriverSigning) {
|
|
||||||
isButtonEnabled =
|
|
||||||
_customerAccepted &&
|
|
||||||
(_noteAccepted || _notesEmpty) &&
|
|
||||||
!_isCustomerSignatureEmpty;
|
|
||||||
} else {
|
|
||||||
isButtonEnabled = !_isDriverSignatureEmpty;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: Text(widget.appBarTitle)),
|
||||||
title:
|
|
||||||
!_isDriverSigning
|
|
||||||
? const Text("Unterschrift des Kunden")
|
|
||||||
: const Text("Unterschrift des Fahrers"),
|
|
||||||
),
|
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(20.0),
|
padding: const EdgeInsets.all(20.0),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height:
|
height: MediaQuery.of(context).size.height * 0.75,
|
||||||
MediaQuery.of(context).size.height *
|
|
||||||
(_isDriverSigning ? 0.75 : 0.5),
|
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: const BoxDecoration(color: Colors.white),
|
decoration: const BoxDecoration(color: Colors.white),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@ -272,7 +334,12 @@ class _SignatureViewState extends State<SignatureView> {
|
|||||||
fontWeight: FontWeight.bold,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Dialog(
|
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(
|
child: SizedBox(
|
||||||
width: MediaQuery.of(context).size.width,
|
width: MediaQuery.of(context).size.width,
|
||||||
height: MediaQuery.of(context).size.height * 0.6,
|
height: MediaQuery.of(context).size.height * 0.6,
|
||||||
@ -115,8 +119,9 @@ class _NoteAddDialogState extends State<NoteAddDialog> {
|
|||||||
maxLines: 10,
|
maxLines: 10,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
Wrap(
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
spacing: 10,
|
||||||
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed:
|
onPressed:
|
||||||
@ -126,15 +131,12 @@ class _NoteAddDialogState extends State<NoteAddDialog> {
|
|||||||
: null,
|
: null,
|
||||||
child: const Text("Hinzufügen"),
|
child: const Text("Hinzufügen"),
|
||||||
),
|
),
|
||||||
Padding(
|
OutlinedButton(
|
||||||
padding: const EdgeInsets.only(left: 10.0),
|
onPressed: () {
|
||||||
child: OutlinedButton(
|
_noteController.clear();
|
||||||
onPressed: () {
|
_noteSelectionController.clear();
|
||||||
_noteController.clear();
|
},
|
||||||
_noteSelectionController.clear();
|
child: const Text("Zurücksetzen"),
|
||||||
},
|
|
||||||
child: const Text("Zurücksetzen"),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
import '../../../bloc/tour_bloc.dart';
|
import '../../../bloc/tour_bloc.dart';
|
||||||
import '../../../bloc/tour_state.dart';
|
import '../../../bloc/tour_state.dart';
|
||||||
|
|
||||||
|
enum _StatusAction { hold, cancel, reactivate }
|
||||||
|
|
||||||
class DeliveryStepInfo extends StatefulWidget {
|
class DeliveryStepInfo extends StatefulWidget {
|
||||||
final Delivery delivery;
|
final Delivery delivery;
|
||||||
|
|
||||||
@ -39,76 +41,73 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
|
|||||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _deliveryStatusChangeActions() {
|
Widget _statusOverflow() {
|
||||||
List<Widget> actions = [];
|
final state = widget.delivery.state;
|
||||||
|
final List<PopupMenuEntry<_StatusAction>> entries;
|
||||||
|
|
||||||
if (widget.delivery.state == DeliveryState.ongoing) {
|
if (state == DeliveryState.ongoing) {
|
||||||
actions = [
|
entries = const [
|
||||||
Column(
|
PopupMenuItem(
|
||||||
children: [
|
value: _StatusAction.hold,
|
||||||
IconButton(
|
child: Row(
|
||||||
onPressed: () {
|
children: [
|
||||||
context.read<TourBloc>().add(
|
Icon(Icons.change_circle, color: Colors.orangeAccent),
|
||||||
HoldDeliveryEvent(deliveryId: widget.delivery.id),
|
SizedBox(width: 12),
|
||||||
);
|
Text("Zurückstellen"),
|
||||||
Navigator.of(context).pop();
|
],
|
||||||
},
|
),
|
||||||
icon: Icon(
|
|
||||||
Icons.change_circle,
|
|
||||||
color: Colors.orangeAccent,
|
|
||||||
size: 42,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text("Zurückstellen"),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
PopupMenuItem(
|
||||||
Column(
|
value: _StatusAction.cancel,
|
||||||
children: [
|
child: Row(
|
||||||
IconButton(
|
children: [
|
||||||
onPressed: () {
|
Icon(Icons.cancel, color: Colors.red),
|
||||||
context.read<TourBloc>().add(
|
SizedBox(width: 12),
|
||||||
CancelDeliveryEvent(deliveryId: widget.delivery.id),
|
Text("Abbrechen"),
|
||||||
);
|
],
|
||||||
|
),
|
||||||
Navigator.of(context).pop();
|
),
|
||||||
},
|
];
|
||||||
//style: IconButton.styleFrom(backgroundColor: Colors.red),
|
} else {
|
||||||
icon: Icon(Icons.cancel, color: Colors.red, size: 42),
|
entries = const [
|
||||||
),
|
PopupMenuItem(
|
||||||
Text("Abbrechen"),
|
value: _StatusAction.reactivate,
|
||||||
],
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.published_with_changes, color: Colors.blueAccent),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text("Reaktivieren"),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.delivery.state == DeliveryState.canceled ||
|
return PopupMenuButton<_StatusAction>(
|
||||||
widget.delivery.state == DeliveryState.onhold ||
|
icon: const Icon(Icons.more_vert),
|
||||||
widget.delivery.state == DeliveryState.finished) {
|
tooltip: "Status ändern",
|
||||||
actions = [
|
itemBuilder: (context) => entries,
|
||||||
Column(
|
onSelected: (action) {
|
||||||
children: [
|
switch (action) {
|
||||||
IconButton(
|
case _StatusAction.hold:
|
||||||
onPressed: () {
|
context.read<TourBloc>().add(
|
||||||
context.read<TourBloc>().add(
|
HoldDeliveryEvent(deliveryId: widget.delivery.id),
|
||||||
ReactivateDeliveryEvent(deliveryId: widget.delivery.id),
|
);
|
||||||
);
|
Navigator.of(context).pop();
|
||||||
},
|
break;
|
||||||
icon: Icon(
|
case _StatusAction.cancel:
|
||||||
Icons.published_with_changes,
|
context.read<TourBloc>().add(
|
||||||
color: Colors.blueAccent,
|
CancelDeliveryEvent(deliveryId: widget.delivery.id),
|
||||||
size: 42
|
);
|
||||||
),
|
Navigator.of(context).pop();
|
||||||
),
|
break;
|
||||||
Text("Reaktivieren"),
|
case _StatusAction.reactivate:
|
||||||
],
|
context.read<TourBloc>().add(
|
||||||
),
|
ReactivateDeliveryEvent(deliveryId: widget.delivery.id),
|
||||||
];
|
);
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
return Row(
|
},
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
children: actions,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,55 +118,46 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
|
|||||||
color: Theme.of(context).colorScheme.onSecondary,
|
color: Theme.of(context).colorScheme.onSecondary,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
child: Column(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Expanded(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
child: Builder(
|
||||||
children: [
|
builder: (context) {
|
||||||
Column(
|
final phone = widget.delivery.contactPerson?.phoneNumber;
|
||||||
children: [
|
final bool hasPhone = phone != null && phone.isNotEmpty;
|
||||||
IconButton.filled(
|
return Column(
|
||||||
onPressed:
|
mainAxisSize: MainAxisSize.min,
|
||||||
widget.delivery.contactPerson?.phoneNumber != null
|
children: [
|
||||||
? () async {
|
IconButton.filled(
|
||||||
|
onPressed: hasPhone
|
||||||
|
? () async {
|
||||||
await launchUrl(
|
await launchUrl(
|
||||||
Uri(
|
Uri(scheme: "tel", path: phone),
|
||||||
scheme: "tel",
|
|
||||||
path:
|
|
||||||
widget
|
|
||||||
.delivery
|
|
||||||
.contactPerson
|
|
||||||
?.phoneNumber!,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
icon: Icon(Icons.phone),
|
icon: const Icon(Icons.phone),
|
||||||
),
|
),
|
||||||
Text("Anrufen"),
|
const Text("Anrufen"),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
},
|
||||||
Column(
|
),
|
||||||
children: [
|
|
||||||
IconButton.filled(
|
|
||||||
onPressed: () {
|
|
||||||
_launchMapsUrl("google");
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.map_outlined),
|
|
||||||
),
|
|
||||||
Text("Google Maps"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
Expanded(
|
||||||
const Padding(
|
child: Column(
|
||||||
padding: EdgeInsets.only(top: 10, bottom: 10),
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Divider(),
|
children: [
|
||||||
|
IconButton.filled(
|
||||||
|
onPressed: () => _launchMapsUrl("google"),
|
||||||
|
icon: const Icon(Icons.map_outlined),
|
||||||
|
),
|
||||||
|
const Text("Google Maps"),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
_statusOverflow(),
|
||||||
_deliveryStatusChangeActions(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -176,6 +166,16 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _customerInformation() {
|
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(
|
return SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: Card(
|
child: Card(
|
||||||
@ -228,9 +228,24 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
|
|||||||
Icon(Icons.phone, color: Theme.of(context).primaryColor),
|
Icon(Icons.phone, color: Theme.of(context).primaryColor),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 10),
|
padding: const EdgeInsets.only(left: 10),
|
||||||
child: Text(
|
child: Text(phoneText),
|
||||||
widget.delivery.contactPerson?.phoneNumber.toString() ??
|
),
|
||||||
"",
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 15),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.mail, color: Theme.of(context).primaryColor),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 10),
|
||||||
|
child: Text(
|
||||||
|
emailText,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -275,28 +290,66 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _deliveryAgreements() {
|
Widget _agreementsAndDesiredTime() {
|
||||||
String agreements = "keine Vereinbarungen getroffen!";
|
String agreements = "keine Vereinbarungen getroffen!";
|
||||||
if (widget.delivery.specialAgreements != null &&
|
if (widget.delivery.specialAgreements != null &&
|
||||||
widget.delivery.specialAgreements != "") {
|
widget.delivery.specialAgreements != "") {
|
||||||
agreements = 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(
|
return Card(
|
||||||
color: Theme.of(context).colorScheme.onSecondary,
|
color: Theme.of(context).colorScheme.onSecondary,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
if (hasDesiredTime) ...[
|
||||||
padding: EdgeInsets.all(15),
|
Row(
|
||||||
child: Icon(
|
children: [
|
||||||
Icons.warning,
|
Padding(
|
||||||
color: Theme.of(context).primaryColor,
|
padding: const EdgeInsets.all(15),
|
||||||
size: 28,
|
child: Icon(Icons.schedule, color: primary, size: 28),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Wunschtermin",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
desiredTime,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
|
],
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(15),
|
||||||
|
child: Icon(Icons.warning, color: primary, size: 28),
|
||||||
|
),
|
||||||
|
Expanded(child: Text(agreements)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Expanded(child: Text(agreements)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -330,7 +383,7 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 10),
|
padding: const EdgeInsets.only(top: 10),
|
||||||
child: _deliveryAgreements(),
|
child: _agreementsAndDesiredTime(),
|
||||||
),
|
),
|
||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
|
|||||||
@ -275,10 +275,14 @@ class NoteService {
|
|||||||
LocalDocuFrameConfiguration config = getConfig();
|
LocalDocuFrameConfiguration config = getConfig();
|
||||||
|
|
||||||
return urls.map((url) async {
|
return urls.map((url) async {
|
||||||
return (await http.get(
|
final response = await http.get(
|
||||||
Uri.parse("${config.backendUrl}$url"),
|
Uri.parse("${config.backendUrl}$url"),
|
||||||
headers: getSessionOrThrow(),
|
headers: getSessionOrThrow(),
|
||||||
)).bodyBytes;
|
);
|
||||||
|
if (response.statusCode == HttpStatus.unauthorized) {
|
||||||
|
throw UserUnauthorized();
|
||||||
|
}
|
||||||
|
return response.bodyBytes;
|
||||||
}).toList();
|
}).toList();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint("An error occured:");
|
debugPrint("An error occured:");
|
||||||
|
|||||||
@ -1,80 +1,86 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hl_lieferservice/model/delivery.dart' show DeliveryState;
|
||||||
import 'package:hl_lieferservice/model/tour.dart';
|
import 'package:hl_lieferservice/model/tour.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class DeliveryInfo extends StatelessWidget {
|
class DeliveryInfo extends StatelessWidget {
|
||||||
final Tour tour;
|
final Tour tour;
|
||||||
|
final int? selectedCarId;
|
||||||
|
|
||||||
const DeliveryInfo({super.key, required this.tour});
|
const DeliveryInfo({super.key, required this.tour, this.selectedCarId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
String date = DateFormat("dd.MM.yyyy").format(tour.date);
|
final String date = DateFormat("dd.MM.yyyy").format(tour.date);
|
||||||
String amountDeliveries = tour.deliveries.length.toString();
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 15),
|
||||||
child: Column(
|
child: SizedBox(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
width: double.infinity,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Card(
|
||||||
children: [
|
color: Theme.of(context).colorScheme.onSecondary,
|
||||||
Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Text(
|
child: Column(
|
||||||
"Informationen",
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
children: [
|
||||||
),
|
Row(
|
||||||
),
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: Card(
|
|
||||||
color: Theme.of(context).colorScheme.onSecondary,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
Row(
|
const Icon(Icons.calendar_month),
|
||||||
children: [
|
const Padding(
|
||||||
Icon(Icons.calendar_month),
|
padding: EdgeInsets.only(left: 5),
|
||||||
Padding(
|
child: Text("Datum"),
|
||||||
padding: const EdgeInsets.only(left: 5),
|
|
||||||
child: Text("Datum"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
Text(date),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
Text(date),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 15),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.local_shipping_outlined),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 5),
|
|
||||||
child: Text("Lieferungen"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Text(amountDeliveries),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 15),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.local_shipping_outlined),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(left: 5),
|
||||||
|
child: Text("Lieferungen"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text("$done / $total"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: progress,
|
||||||
|
minHeight: 6,
|
||||||
|
backgroundColor:
|
||||||
|
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
allDone ? Colors.green : Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
|
||||||
import 'package:hl_lieferservice/model/delivery.dart';
|
import 'package:hl_lieferservice/model/delivery.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.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 {
|
class DeliveryListItem extends StatelessWidget {
|
||||||
final Delivery delivery;
|
final Delivery delivery;
|
||||||
final double distance;
|
final double? distance;
|
||||||
|
|
||||||
const DeliveryListItem({
|
const DeliveryListItem({
|
||||||
super.key,
|
super.key,
|
||||||
required this.delivery,
|
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) {
|
void _goToDelivery(BuildContext context) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder:
|
builder: (context) => BlocProvider(
|
||||||
(context) => BlocProvider(
|
create: (context) => NoteBloc(
|
||||||
create:
|
deliveryId: delivery.id,
|
||||||
(context) => NoteBloc(
|
opBloc: context.read<OperationBloc>(),
|
||||||
deliveryId: delivery.id,
|
authBloc: context.read<AuthBloc>(),
|
||||||
opBloc: context.read<OperationBloc>(),
|
repository: NoteRepository(service: NoteService()),
|
||||||
repository: NoteRepository(
|
),
|
||||||
service: NoteService(),
|
child: DeliveryDetail(deliveryId: delivery.id),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
child: DeliveryDetail(deliveryId: delivery.id),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(Color, Color, IconData, String) _stateStyle(BuildContext context) {
|
||||||
|
switch (delivery.state) {
|
||||||
|
case DeliveryState.finished:
|
||||||
|
return (
|
||||||
|
Colors.green.withValues(alpha: 0.07),
|
||||||
|
Colors.green.withValues(alpha: 0.35),
|
||||||
|
Icons.check_circle_rounded,
|
||||||
|
"Abgeschlossen",
|
||||||
|
);
|
||||||
|
case DeliveryState.canceled:
|
||||||
|
return (
|
||||||
|
Colors.red.withValues(alpha: 0.07),
|
||||||
|
Colors.red.withValues(alpha: 0.35),
|
||||||
|
Icons.cancel_rounded,
|
||||||
|
"Storniert",
|
||||||
|
);
|
||||||
|
case DeliveryState.onhold:
|
||||||
|
return (
|
||||||
|
Colors.orange.withValues(alpha: 0.07),
|
||||||
|
Colors.orange.withValues(alpha: 0.35),
|
||||||
|
Icons.pause_circle_rounded,
|
||||||
|
"Pausiert",
|
||||||
|
);
|
||||||
|
case DeliveryState.ongoing:
|
||||||
|
final distanceLabel = distance != null && !distance!.isNaN
|
||||||
|
? "${distance!.toStringAsFixed(1)} km"
|
||||||
|
: "–";
|
||||||
|
return (
|
||||||
|
Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
|
Colors.transparent,
|
||||||
|
Icons.local_shipping_outlined,
|
||||||
|
distanceLabel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
final (cardColor, borderColor, icon, statusLabel) = _stateStyle(context);
|
||||||
title: Text(
|
final isOngoing = delivery.state == DeliveryState.ongoing;
|
||||||
delivery.customer.name,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
final iconColor = switch (delivery.state) {
|
||||||
|
DeliveryState.finished => Colors.green,
|
||||||
|
DeliveryState.canceled => Colors.red,
|
||||||
|
DeliveryState.onhold => Colors.orange,
|
||||||
|
DeliveryState.ongoing => Theme.of(context).primaryColor,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
elevation: 0,
|
||||||
|
color: cardColor,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(color: borderColor),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () => _goToDelivery(context),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: iconColor, size: 28),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
delivery.customer.name,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isOngoing ? null : iconColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
delivery.customer.address.toString(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
statusLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: isOngoing
|
||||||
|
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||||
|
: iconColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Icon(
|
||||||
|
Icons.arrow_forward_ios,
|
||||||
|
size: 14,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
leading: _leading(context),
|
|
||||||
tileColor: Theme.of(context).colorScheme.surfaceContainerHigh,
|
|
||||||
subtitle: Text(delivery.customer.address.toString()),
|
|
||||||
trailing: Icon(Icons.arrow_forward_ios),
|
|
||||||
onTap: () => _goToDelivery(context),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/overview/model/sorting_information.dart';
|
|
||||||
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart';
|
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart';
|
||||||
import 'package:hl_lieferservice/model/delivery.dart';
|
import 'package:hl_lieferservice/model/delivery.dart';
|
||||||
|
|
||||||
@ -43,7 +43,7 @@ class _DeliveryListState extends State<DeliveryList> {
|
|||||||
|
|
||||||
return DeliveryListItem(
|
return DeliveryListItem(
|
||||||
delivery: delivery,
|
delivery: delivery,
|
||||||
distance: distances[delivery.id] ?? 0.0,
|
distance: distances[delivery.id],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
itemCount: sortingInformation.length,
|
itemCount: sortingInformation.length,
|
||||||
@ -56,79 +56,66 @@ class _DeliveryListState extends State<DeliveryList> {
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is TourLoaded) {
|
if (currentState is TourLoaded) {
|
||||||
List<Delivery> deliveries =
|
if (widget.sortType == SortType.custom) {
|
||||||
currentState.tour.deliveries
|
return _showCustomSortedList(
|
||||||
.where(
|
currentState.tour.deliveries,
|
||||||
(delivery) =>
|
currentState.sortingInformation[widget.selectedCarId.toString()] ?? [],
|
||||||
delivery.carId == widget.selectedCarId &&
|
currentState.distances ?? {},
|
||||||
delivery.allArticlesScanned() &&
|
);
|
||||||
delivery.state != DeliveryState.finished,
|
}
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
List<Delivery> finishedDeliveries =
|
final allDeliveries = currentState.tour.deliveries
|
||||||
currentState.tour.deliveries
|
.where((d) => d.carId == widget.selectedCarId)
|
||||||
.where(
|
.toList();
|
||||||
(delivery) =>
|
|
||||||
delivery.state == DeliveryState.finished &&
|
|
||||||
delivery.carId == widget.selectedCarId,
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (deliveries.isEmpty) {
|
if (allDeliveries.isEmpty) {
|
||||||
return ListView(
|
return ListView(
|
||||||
physics: NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
children: [
|
children: const [
|
||||||
Center(child: const Text("Keine Auslieferungen gefunden")),
|
Center(child: Text("Keine Auslieferungen gefunden")),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final ongoing = allDeliveries
|
||||||
|
.where((d) => d.state == DeliveryState.ongoing)
|
||||||
|
.toList();
|
||||||
|
final nonOngoing = allDeliveries
|
||||||
|
.where((d) => d.state != DeliveryState.ongoing)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
int Function(Delivery, Delivery) comparator;
|
||||||
switch (widget.sortType) {
|
switch (widget.sortType) {
|
||||||
case SortType.custom:
|
|
||||||
return _showCustomSortedList(
|
|
||||||
currentState.tour.deliveries,
|
|
||||||
currentState.sortingInformation[widget.selectedCarId.toString()] ?? [],
|
|
||||||
currentState.distances ?? {},
|
|
||||||
);
|
|
||||||
|
|
||||||
case SortType.nameAsc:
|
case SortType.nameAsc:
|
||||||
deliveries.sort(
|
comparator = (a, b) => a.customer.name.compareTo(b.customer.name);
|
||||||
(a, b) => a.customer.name.compareTo(b.customer.name),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SortType.nameDesc:
|
case SortType.nameDesc:
|
||||||
deliveries.sort(
|
comparator = (a, b) => b.customer.name.compareTo(a.customer.name);
|
||||||
(a, b) => b.customer.name.compareTo(a.customer.name),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SortType.distance:
|
case SortType.distance:
|
||||||
deliveries.sort(
|
comparator = (a, b) =>
|
||||||
(a, b) => (currentState.distances![a.id] ?? 0.0).compareTo(
|
(currentState.distances?[a.id] ?? 0.0)
|
||||||
currentState.distances![b.id] ?? 0.0,
|
.compareTo(currentState.distances?[b.id] ?? 0.0);
|
||||||
),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
|
default:
|
||||||
|
comparator = (a, b) => a.customer.name.compareTo(b.customer.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
//deliveries.addAll(finishedDeliveries);
|
ongoing.sort(comparator);
|
||||||
|
nonOngoing.sort(comparator);
|
||||||
|
|
||||||
return ListView.separated(
|
final sorted = [...ongoing, ...nonOngoing];
|
||||||
separatorBuilder: (context, index) => const Divider(height: 0),
|
|
||||||
|
return ListView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemBuilder: (context, index) {
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
Delivery delivery = deliveries[index];
|
itemCount: sorted.length,
|
||||||
|
itemBuilder: (context, index) => DeliveryListItem(
|
||||||
return DeliveryListItem(
|
delivery: sorted[index],
|
||||||
delivery: delivery,
|
distance: currentState.distances?[sorted[index].id],
|
||||||
distance: currentState.distances?[delivery.id] ?? 0.0,
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
itemCount: deliveries.length,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
|
||||||
|
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_info.dart';
|
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_info.dart';
|
||||||
@ -16,11 +18,9 @@ class DeliveryOverview extends StatefulWidget {
|
|||||||
const DeliveryOverview({
|
const DeliveryOverview({
|
||||||
super.key,
|
super.key,
|
||||||
required this.tour,
|
required this.tour,
|
||||||
required this.distances,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final Tour tour;
|
final Tour tour;
|
||||||
final Map<String, double> distances;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StatefulWidget> createState() => _DeliveryOverviewState();
|
State<StatefulWidget> createState() => _DeliveryOverviewState();
|
||||||
@ -34,8 +34,14 @@ class _DeliveryOverviewState extends State<DeliveryOverview> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
// Select the first car for initialization
|
// Pre-select today's car from the daily car selection.
|
||||||
_selectedCarId = widget.tour.driver.cars.firstOrNull?.id;
|
// 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;
|
_sortType = SortType.nameAsc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,54 +50,6 @@ class _DeliveryOverviewState extends State<DeliveryOverview> {
|
|||||||
context.read<TourBloc>().add(LoadTour(teamId: state.user.number));
|
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.
|
/// Highlight the text of the active sorting type.
|
||||||
TextStyle? _popupItemTextStyle() {
|
TextStyle? _popupItemTextStyle() {
|
||||||
return TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold);
|
return TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold);
|
||||||
@ -99,17 +57,23 @@ class _DeliveryOverviewState extends State<DeliveryOverview> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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,
|
onRefresh: _loadTour,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
//crossAxisAlignment: CrossAxisAlignment.start,
|
//crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
DeliveryInfo(tour: widget.tour),
|
DeliveryInfo(tour: widget.tour, selectedCarId: _selectedCarId),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
left: 10,
|
left: 10,
|
||||||
right: 10,
|
right: 10,
|
||||||
top: 15,
|
top: 0,
|
||||||
bottom: 10,
|
bottom: 10,
|
||||||
),
|
),
|
||||||
child: Row(
|
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(
|
DeliveryList(
|
||||||
selectedCarId: _selectedCarId,
|
selectedCarId: _selectedCarId,
|
||||||
sortType: _sortType,
|
sortType: _sortType,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
|
||||||
|
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.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/feature/delivery/overview/presentation/delivery_overview.dart';
|
||||||
|
import 'package:hl_lieferservice/model/tour.dart';
|
||||||
import '../../bloc/tour_bloc.dart';
|
import '../../bloc/tour_bloc.dart';
|
||||||
import '../../bloc/tour_state.dart';
|
import '../../bloc/tour_state.dart';
|
||||||
|
|
||||||
@ -14,27 +16,90 @@ class DeliveryOverviewPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<TourBloc, TourState>(
|
final carState = context.watch<CarSelectBloc>().state;
|
||||||
builder: (context, state) {
|
|
||||||
if (state is TourLoaded) {
|
|
||||||
final currentState = state;
|
|
||||||
|
|
||||||
return Center(
|
return Scaffold(
|
||||||
child: DeliveryOverview(
|
appBar: AppBar(
|
||||||
tour: currentState.tour,
|
title: const Text("Auslieferung"),
|
||||||
distances: currentState.distances ?? {},
|
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) {
|
||||||
|
if (state.distances == null) {
|
||||||
|
return _buildOverviewWithBanner(
|
||||||
|
tour: state.tour,
|
||||||
|
bannerText: "Berechne Distanzen…",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return DeliveryOverview(tour: state.tour);
|
||||||
|
}
|
||||||
|
|
||||||
if (state is TourLoadingFailed) {
|
if (state is TourLoadingFailed) {
|
||||||
return DeliveryLoadingFailedPage();
|
return DeliveryLoadingFailedPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container();
|
return const Center(child: CircularProgressIndicator());
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -80,6 +80,8 @@ class TourRepository {
|
|||||||
|
|
||||||
if (article.scannedAmount < article.amount) {
|
if (article.scannedAmount < article.amount) {
|
||||||
article.scannedAmount += 1;
|
article.scannedAmount += 1;
|
||||||
|
delivery.carId = int.tryParse(carId) ?? delivery.carId;
|
||||||
|
await service.assignCar(deliveryId, carId);
|
||||||
_tourStream.add(tour);
|
_tourStream.add(tour);
|
||||||
return ScanResult.scanned;
|
return ScanResult.scanned;
|
||||||
} else {
|
} 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(
|
Future<void> unscan(
|
||||||
String deliveryId,
|
String deliveryId,
|
||||||
String articleId,
|
String articleId,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:hl_lieferservice/dto/delivery_response.dart';
|
import 'package:hl_lieferservice/dto/delivery_response.dart';
|
||||||
import 'package:hl_lieferservice/dto/delivery_update.dart';
|
import 'package:hl_lieferservice/dto/delivery_update.dart';
|
||||||
import 'package:hl_lieferservice/dto/delivery_update_response.dart';
|
import 'package:hl_lieferservice/dto/delivery_update_response.dart';
|
||||||
@ -271,10 +272,24 @@ class TourService {
|
|||||||
|
|
||||||
Future<BasicResponseDTO> finishDelivery(String deliveryId) async {
|
Future<BasicResponseDTO> finishDelivery(String deliveryId) async {
|
||||||
try {
|
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(
|
var response = await post(
|
||||||
urlBuilder("_web_finishDelivery"),
|
urlBuilder("_web_finishDelivery"),
|
||||||
headers: getSessionOrThrow(),
|
headers: headers,
|
||||||
body: {"delivery_id": deliveryId},
|
body: jsonEncode({
|
||||||
|
"delivery_id": deliveryId,
|
||||||
|
"delivered_at": deliveredAt,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == HttpStatus.unauthorized) {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final screenHeight = MediaQuery.of(context).size.height;
|
|
||||||
final scannerHeight = screenHeight / 4;
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: scannerHeight,
|
height: 150,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _isDetected ? Colors.green : Colors.grey,
|
color: _isDetected ? Colors.green : Colors.grey,
|
||||||
|
|||||||
@ -122,10 +122,9 @@ class _SettingsPage extends State<SettingsPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: const Text("Einstellungen"),
|
||||||
"Einstellungen",
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
foregroundColor: Theme.of(context).colorScheme.onSecondary,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import 'package:hl_lieferservice/dto/article.dart';
|
import 'package:hl_lieferservice/dto/article.dart';
|
||||||
|
|
||||||
|
import 'component.dart';
|
||||||
|
|
||||||
class Article {
|
class Article {
|
||||||
Article({
|
Article({
|
||||||
required this.name,
|
required this.name,
|
||||||
@ -11,13 +13,21 @@ class Article {
|
|||||||
required this.scannable,
|
required this.scannable,
|
||||||
required this.scannedAmount,
|
required this.scannedAmount,
|
||||||
required this.scannedRemovedAmount,
|
required this.scannedRemovedAmount,
|
||||||
|
required this.isParent,
|
||||||
|
this.components = const [],
|
||||||
this.scannedDate,
|
this.scannedDate,
|
||||||
this.removeNoteId
|
this.removeNoteId,
|
||||||
|
this.warehouseNr,
|
||||||
|
this.warehouseName,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String name;
|
final String name;
|
||||||
final String articleNumber;
|
final String articleNumber;
|
||||||
final int internalId;
|
final int internalId;
|
||||||
|
final bool isParent;
|
||||||
|
final List<Component> components;
|
||||||
|
final String? warehouseNr;
|
||||||
|
final String? warehouseName;
|
||||||
|
|
||||||
int amount;
|
int amount;
|
||||||
double price;
|
double price;
|
||||||
@ -36,7 +46,35 @@ class Article {
|
|||||||
return price * scannedAmount * ((100 + tax) / 100);
|
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() {
|
bool unscanned() {
|
||||||
|
if (isParent && components.isNotEmpty) {
|
||||||
|
return components.every((c) => c.scannedAmount == 0);
|
||||||
|
}
|
||||||
return scannedAmount == 0;
|
return scannedAmount == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,6 +96,11 @@ class Article {
|
|||||||
price: double.parse(dto.price == "" ? "0.0" : dto.price),
|
price: double.parse(dto.price == "" ? "0.0" : dto.price),
|
||||||
scannable: dto.scannable,
|
scannable: dto.scannable,
|
||||||
tax: double.parse(dto.taxRate == "" ? "19" : dto.taxRate),
|
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';
|
import 'address.dart';
|
||||||
|
|
||||||
class Customer {
|
class Customer {
|
||||||
const Customer({required this.name, required this.address});
|
const Customer({required this.name, required this.address, this.email});
|
||||||
|
|
||||||
final String name;
|
final String name;
|
||||||
final Address address;
|
final Address address;
|
||||||
|
final String? email;
|
||||||
|
|
||||||
factory Customer.fromDTO(CustomerDTO dto) {
|
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() {
|
List<Article> getDeliveredArticles() {
|
||||||
return articles
|
return articles
|
||||||
.where(
|
.where((article) {
|
||||||
(article) => article.scannedAmount > 0 || !article.scannable,
|
if (!article.scannable) return true;
|
||||||
)
|
if (article.isParent && article.components.isNotEmpty) {
|
||||||
|
return article.isFullyScanned;
|
||||||
|
}
|
||||||
|
return article.scannedAmount > 0;
|
||||||
|
})
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool containsArticle(String articleNr) {
|
bool containsArticle(String articleNr) {
|
||||||
return articles.any((article) => article.articleNumber == articleNr);
|
return articles.any((article) => article.hasArticleNumber(articleNr));
|
||||||
}
|
}
|
||||||
|
|
||||||
Article getArticle(String nr) {
|
Article getArticle(String nr) {
|
||||||
return articles.firstWhere((article) => article.articleNumber == 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() {
|
List<Article> getScannableArticles() {
|
||||||
return articles.where((article) => article.scannable).toList();
|
return articles.where((article) => article.scannable).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool allArticlesScanned() {
|
bool allArticlesScanned() {
|
||||||
return getScannableArticles().every(
|
return getScannableArticles().every(
|
||||||
(article) =>
|
(article) => article.isFullyScanned,
|
||||||
article.amount ==
|
|
||||||
article.scannedAmount + article.scannedRemovedAmount,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -60,6 +60,23 @@ class Tour {
|
|||||||
.length;
|
.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({
|
Tour copyWith({
|
||||||
DateTime? date,
|
DateTime? date,
|
||||||
String? discountArticleNumber,
|
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/bloc/auth_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/authentication/presentation/login_enforcer.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/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/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/bloc/tour_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/repository/tour_repository.dart';
|
import 'package:hl_lieferservice/feature/delivery/repository/tour_repository.dart';
|
||||||
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
|
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
|
||||||
@ -46,33 +52,50 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
|||||||
create:
|
create:
|
||||||
(context) => TourBloc(
|
(context) => TourBloc(
|
||||||
opBloc: context.read<OperationBloc>(),
|
opBloc: context.read<OperationBloc>(),
|
||||||
|
authBloc: context.read<AuthBloc>(),
|
||||||
tourRepository: TourRepository(
|
tourRepository: TourRepository(
|
||||||
service: TourService(),
|
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(
|
child: MaterialApp(
|
||||||
home: OperationViewEnforcer(
|
// Wrap the Navigator (not just the home route) so the loading
|
||||||
child: BlocBuilder<AppBloc, AppState>(
|
// overlay covers every pushed route — DeliveryDetail, Cars,
|
||||||
builder: (context, state) {
|
// dialogs, etc. — not only the initial home tree.
|
||||||
if (state is AppConfigLoading) {
|
builder: (context, child) =>
|
||||||
return Scaffold(
|
OperationViewEnforcer(child: child ?? const SizedBox.shrink()),
|
||||||
body: Center(child: CircularProgressIndicator()),
|
home: BlocBuilder<AppBloc, AppState>(
|
||||||
);
|
builder: (context, state) {
|
||||||
}
|
if (state is AppConfigLoading) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (state is AppConfigLoadingFailed) {
|
if (state is AppConfigLoadingFailed) {
|
||||||
return Scaffold(body: Center(child: Text(state.message)));
|
return Scaffold(body: Center(child: Text(state.message)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state is AppConfigLoaded) {
|
if (state is AppConfigLoaded) {
|
||||||
return LoginEnforcer(child: Home());
|
return LoginEnforcer(
|
||||||
}
|
child: CarSelectionEnforcer(child: Home()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Container();
|
return Container();
|
||||||
},
|
},
|
||||||
),
|
|
||||||
),
|
),
|
||||||
routes: {"/cars": (context) => CarManagementPage()},
|
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/bloc/tour_event.dart';
|
||||||
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview_page.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/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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/widget/home/bloc/navigation_state.dart';
|
import 'package:hl_lieferservice/widget/home/bloc/navigation_state.dart';
|
||||||
import 'package:hl_lieferservice/widget/navigation_bar/presentation/navigation_bar.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 {
|
class Home extends StatefulWidget {
|
||||||
const Home({super.key});
|
const Home({super.key});
|
||||||
@ -44,14 +41,11 @@ class _HomeState extends State<Home> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (index == 2) {
|
if (index == 2) {
|
||||||
return BlocProvider(
|
return CarManagementPage();
|
||||||
create:
|
}
|
||||||
(context) => CarsBloc(
|
|
||||||
repository: CarsRepository(service: CarService()),
|
if (index == 3) {
|
||||||
opBloc: context.read<OperationBloc>(),
|
return SettingsPage();
|
||||||
),
|
|
||||||
child: CarManagementPage(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container();
|
return Container();
|
||||||
@ -64,12 +58,14 @@ class _HomeState extends State<Home> {
|
|||||||
final currentState = state as NavigationInfo;
|
final currentState = state as NavigationInfo;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: PreferredSize(
|
|
||||||
preferredSize: Size.fromHeight(kToolbarHeight),
|
|
||||||
child: CustomAppBar(),
|
|
||||||
),
|
|
||||||
body: _buildPage(currentState.navigationIndex),
|
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),
|
icon: Icon(Icons.local_shipping),
|
||||||
label: "Fahrzeuge",
|
label: "Fahrzeuge",
|
||||||
),
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.settings_outlined),
|
||||||
|
selectedIcon: Icon(Icons.settings),
|
||||||
|
label: "Einstellungen",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
onDestinationSelected: (int index) {
|
onDestinationSelected: (int index) {
|
||||||
context.read<NavigationBloc>().add(NavigateToIndex(index: 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';
|
import 'package:hl_lieferservice/widget/operations/bloc/operation_state.dart';
|
||||||
|
|
||||||
class OperationBloc extends Bloc<OperationEvent, OperationState> {
|
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()) {
|
OperationBloc() : super(OperationIdle()) {
|
||||||
on<LoadOperation>(_loadOperation);
|
on<StartOperation>(_startOperation);
|
||||||
on<FailOperation>(_failOperation);
|
on<FailOperation>(_failOperation);
|
||||||
on<FinishOperation>(_finishOperation);
|
on<FinishOperation>(_finishOperation);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadOperation(LoadOperation event, Emitter<OperationState> emit) async {
|
Future<void> _startOperation(
|
||||||
emit(OperationLoading());
|
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 {
|
Future<void> _finishOperation(
|
||||||
|
FinishOperation event,
|
||||||
|
Emitter<OperationState> emit,
|
||||||
|
) async {
|
||||||
|
_inFlightCount = (_inFlightCount - 1).clamp(0, 1 << 30);
|
||||||
|
|
||||||
|
if (event.message != null) {
|
||||||
|
emit(OperationFinished(message: event.message));
|
||||||
|
await Future.delayed(const 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));
|
emit(OperationFailed(message: event.message));
|
||||||
|
await Future.delayed(const Duration(seconds: 5));
|
||||||
|
|
||||||
await Future.delayed(Duration(seconds: 5));
|
if (_inFlightCount > 0) {
|
||||||
|
emit(OperationInProgress());
|
||||||
emit(OperationIdle());
|
} else {
|
||||||
|
_overlayStartedAt = null;
|
||||||
|
emit(OperationIdle());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _finishOperation(FinishOperation event, Emitter<OperationState> emit) async {
|
Future<void> _awaitMinimumOverlayDuration() async {
|
||||||
emit(OperationFinished(message: event.message));
|
final startedAt = _overlayStartedAt;
|
||||||
|
if (startedAt == null) return;
|
||||||
await Future.delayed(Duration(seconds: 5));
|
final elapsed = DateTime.now().difference(startedAt);
|
||||||
|
if (elapsed < _minimumDisplayDuration) {
|
||||||
emit(OperationIdle());
|
await Future.delayed(_minimumDisplayDuration - elapsed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
abstract class OperationEvent {}
|
abstract class OperationEvent {}
|
||||||
|
|
||||||
class LoadOperation extends OperationEvent {}
|
class StartOperation extends OperationEvent {
|
||||||
|
String? message;
|
||||||
|
|
||||||
|
StartOperation({this.message});
|
||||||
|
}
|
||||||
|
|
||||||
class FailOperation extends OperationEvent {
|
class FailOperation extends OperationEvent {
|
||||||
String message;
|
String message;
|
||||||
@ -12,4 +16,4 @@ class FinishOperation extends OperationEvent {
|
|||||||
String? message;
|
String? message;
|
||||||
|
|
||||||
FinishOperation({this.message});
|
FinishOperation({this.message});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,11 @@ abstract class OperationState {}
|
|||||||
|
|
||||||
class OperationIdle extends OperationState {}
|
class OperationIdle extends OperationState {}
|
||||||
|
|
||||||
class OperationLoading extends OperationState {}
|
class OperationInProgress extends OperationState {
|
||||||
|
String? message;
|
||||||
|
|
||||||
|
OperationInProgress({this.message});
|
||||||
|
}
|
||||||
|
|
||||||
class OperationFailed extends OperationState {
|
class OperationFailed extends OperationState {
|
||||||
String message;
|
String message;
|
||||||
@ -14,4 +18,4 @@ class OperationFinished extends OperationState {
|
|||||||
String? message;
|
String? message;
|
||||||
|
|
||||||
OperationFinished({this.message});
|
OperationFinished({this.message});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,47 +4,24 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
|
|||||||
|
|
||||||
import '../bloc/operation_state.dart';
|
import '../bloc/operation_state.dart';
|
||||||
|
|
||||||
/// OperationViewEnforcer
|
/// Listens to [OperationBloc] and shows:
|
||||||
///
|
/// - SnackBars for success and error messages.
|
||||||
/// A view that encapsulates the functionality to react to asynchronous operations.
|
/// - A blocking modal barrier with a spinner while a mutation is in flight,
|
||||||
/// It is capable of showing a loading indicator while an operation is ongoing and it shows
|
/// so the user gets unambiguous "wait" feedback and cannot double-tap or
|
||||||
/// a error message if the operation failed.
|
/// navigate away mid-request.
|
||||||
class OperationViewEnforcer extends StatefulWidget {
|
class OperationViewEnforcer extends StatelessWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
const OperationViewEnforcer({super.key, required this.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocListener<OperationBloc, OperationState>(
|
return BlocConsumer<OperationBloc, OperationState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is OperationLoading) {
|
if (state is OperationFinished && state.message != null) {
|
||||||
if (_overlayEntry == null) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
_overlayEntry = _createOverlayEntry(context);
|
SnackBar(content: Text(state.message!)),
|
||||||
Overlay.of(context).insert(_overlayEntry!);
|
);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_overlayEntry?.remove();
|
|
||||||
_overlayEntry = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state is OperationFinished) {
|
|
||||||
if (state.message != null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(state.message!)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state is OperationFailed) {
|
if (state is OperationFailed) {
|
||||||
@ -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 Stack(
|
||||||
return OverlayEntry(
|
children: [
|
||||||
builder: (context) => DecoratedBox(
|
child,
|
||||||
decoration: const BoxDecoration(
|
if (isInProgress)
|
||||||
color: Color.fromRGBO(128, 128, 128, 0.8),
|
PopScope(
|
||||||
),
|
canPop: false,
|
||||||
child: const Center(
|
child: Stack(
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
children: [
|
||||||
),
|
const ModalBarrier(
|
||||||
),
|
dismissible: false,
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
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
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -668,26 +668,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1033,10 +1033,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.10"
|
||||||
timing:
|
timing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user