Compare commits

...

3 Commits

Author SHA1 Message Date
ac6b03227d Added components to article 2026-05-11 17:12:05 +02:00
2470299a10 BIG FAT 2026-04-28 13:03:09 +02:00
de8668c11a MEGA BIG FAT 2026-04-28 13:02:40 +02:00
68 changed files with 3391 additions and 1739 deletions

31
CLAUDE.md Normal file
View 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
View 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 13 bzw. 46) 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 13: keine Fahrer-Signatur, kein Abschluss.
- Fehler in 46: 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 16 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.

View File

@ -20,7 +20,5 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View File

@ -15,7 +15,7 @@
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
997C0E4FB7B2C67AB8388B3F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FB0CFA44E0F4A317CC3E8B41 /* Pods_RunnerTests.framework */; };
CAFC9CC8D4DE6A37AF21BCD7 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0F2499FC75E94DB5A00A1507 /* Pods_Runner.framework */; };
A8B379B8CE90BAD414DCE8D3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0F2499FC75E94DB5A00A1507 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -80,7 +80,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
CAFC9CC8D4DE6A37AF21BCD7 /* Pods_Runner.framework in Frameworks */,
A8B379B8CE90BAD414DCE8D3 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -2,12 +2,15 @@ import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

View File

@ -2,19 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Kamera-Berechtigung -->
<key>NSCameraUsageDescription</key>
<string>Wir benötigen Zugriff auf deine Kamera zum Scannen von Barcodes.</string>
<!-- Weitere iOS-Einstellungen -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Diese App benötigt keinen Standortzugriff.</string>
<!-- GPS Permissions -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Diese App benötigt deinen Standort, um die Lieferdistanz zu berechnen.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Diese App benötigt deinen Standort, um die Lieferdistanz zu berechnen.</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@ -33,10 +22,50 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Wir benötigen Zugriff auf deine Kamera zum Scannen von Barcodes.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Diese App benötigt deinen Standort, um die Lieferdistanz zu berechnen.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Diese App benötigt deinen Standort, um die Lieferdistanz zu berechnen.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@ -54,21 +83,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@ -1,5 +1,7 @@
import 'package:json_annotation/json_annotation.dart';
import 'component.dart';
part 'article.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
@ -15,6 +17,10 @@ class ArticleDTO {
required this.scannedAmount,
required this.removeNoteId,
required this.taxRate,
required this.isParent,
this.components,
this.warehouseNr,
this.warehouseName,
});
String name;
@ -27,6 +33,10 @@ class ArticleDTO {
String scannedRemovedAmount;
String? removeNoteId;
bool scannable;
bool isParent;
List<ComponentDTO>? components;
String? warehouseNr;
String? warehouseName;
factory ArticleDTO.fromJson(Map<String, dynamic> json) =>
_$ArticleDTOFromJson(json);

View File

@ -17,6 +17,13 @@ ArticleDTO _$ArticleDTOFromJson(Map<String, dynamic> json) => ArticleDTO(
scannedAmount: json['scanned_amount'] as String,
removeNoteId: json['remove_note_id'] as String?,
taxRate: json['tax_rate'] as String,
isParent: json['is_parent'] as bool,
components:
(json['components'] as List<dynamic>?)
?.map((e) => ComponentDTO.fromJson(e as Map<String, dynamic>))
.toList(),
warehouseNr: json['warehouse_nr'] as String?,
warehouseName: json['warehouse_name'] as String?,
);
Map<String, dynamic> _$ArticleDTOToJson(ArticleDTO instance) =>
@ -31,4 +38,8 @@ Map<String, dynamic> _$ArticleDTOToJson(ArticleDTO instance) =>
'scanned_removed_amount': instance.scannedRemovedAmount,
'remove_note_id': instance.removeNoteId,
'scannable': instance.scannable,
'is_parent': instance.isParent,
'components': instance.components,
'warehouse_nr': instance.warehouseNr,
'warehouse_name': instance.warehouseName,
};

23
lib/dto/component.dart Normal file
View 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
View 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,
};

View File

@ -5,10 +5,11 @@ part 'customer.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class CustomerDTO {
CustomerDTO({required this.name, required this.address});
CustomerDTO({required this.name, required this.address, this.eMail});
String name;
AddressDTO address;
String? eMail;
factory CustomerDTO.fromJson(Map<String, dynamic> json) => _$CustomerDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$CustomerDTOToJson(this);

View File

@ -9,7 +9,12 @@ part of 'customer.dart';
CustomerDTO _$CustomerDTOFromJson(Map<String, dynamic> json) => CustomerDTO(
name: json['name'] as String,
address: AddressDTO.fromJson(json['address'] as Map<String, dynamic>),
eMail: json['e_mail'] as String?,
);
Map<String, dynamic> _$CustomerDTOToJson(CustomerDTO instance) =>
<String, dynamic>{'name': instance.name, 'address': instance.address};
<String, dynamic>{
'name': instance.name,
'address': instance.address,
'e_mail': instance.eMail,
};

View File

@ -1,4 +1,5 @@
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:intl/intl.dart';
import 'package:json_annotation/json_annotation.dart';
part 'delivery_update.g.dart';
@ -74,7 +75,9 @@ class DeliveryUpdateDTO {
carId: delivery.carId?.toString() ,
selectedPaymentMethodId: delivery.payment.id,
options: delivery.options.map(DeliveryOptionUpdateDTO.fromEntity).toList(),
finishedDate: DateTime.now().millisecondsSinceEpoch.toString()
finishedDate: delivery.state == DeliveryState.finished
? DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now())
: null,
);
}

View File

@ -15,28 +15,27 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
: super(Unauthenticated()) {
on<SetAuthenticatedEvent>(_auth);
on<Logout>(_logout);
on<SessionExpiredEvent>(_sessionExpired);
}
Future<void> _auth(
SetAuthenticatedEvent event,
Emitter<AuthState> emit,
) async {
operationBloc.add(LoadOperation());
await Future.delayed(Duration(seconds: 5));
try {
debugPrint("Retrieve user information");
emit(Authenticating());
var response = await service.getUserinfo(event.sessionId);
var state = Authenticated(sessionId: event.sessionId, user: response);
locator.registerSingleton<Authenticated>(state);
emit(state);
operationBloc.add(FinishOperation());
} catch (err, st) {
debugPrint("Failed to retrieve user information");
debugPrint(err.toString());
debugPrint(st.toString());
emit(Unauthenticated());
operationBloc.add(
FailOperation(
message: "Login war nicht erfolgreich. Probieren Sie es erneut.",
@ -46,6 +45,19 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
}
Future<void> _logout(Logout event, Emitter<AuthState> emit) async {
if (locator.isRegistered<Authenticated>()) {
locator.unregister<Authenticated>();
}
emit(Unauthenticated());
}
Future<void> _sessionExpired(
SessionExpiredEvent event,
Emitter<AuthState> emit,
) async {
if (locator.isRegistered<Authenticated>()) {
locator.unregister<Authenticated>();
}
emit(Unauthenticated(sessionExpired: true));
}
}

View File

@ -10,4 +10,6 @@ class Logout extends AuthEvent {
String username;
Logout({required this.username});
}
}
class SessionExpiredEvent extends AuthEvent {}

View File

@ -2,7 +2,15 @@ import 'package:hl_lieferservice/feature/authentication/model/user.dart';
abstract class AuthState {}
class Unauthenticated extends AuthState {}
class Unauthenticated extends AuthState {
final bool sessionExpired;
Unauthenticated({this.sessionExpired = false});
}
/// Transient state while [SetAuthenticatedEvent] is being processed and the
/// user info is being fetched from the server.
class Authenticating extends AuthState {}
class Authenticated extends AuthState {
User user;
String sessionId;

View File

@ -18,7 +18,8 @@ class LoginEnforcer extends StatelessWidget {
return child;
}
return LoginPage();
final expired = state is Unauthenticated && state.sessionExpired;
return LoginPage(sessionExpired: expired);
},
);
}

View File

@ -3,11 +3,15 @@ import 'package:app_links/app_links.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:hl_lieferservice/util.dart';
import 'dart:async';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
final bool sessionExpired;
const LoginPage({super.key, this.sessionExpired = false});
@override
State<StatefulWidget> createState() => _LoginPageState();
@ -58,9 +62,7 @@ class _LoginPageState extends State<LoginPage> {
// Small delay to ensure listener is ready
await Future.delayed(const Duration(milliseconds: 500));
debugPrint("🔵 Opening browser to: http://localhost:3000/login");
final loginUrl = Uri.parse('http://100.72.100.33:3000/login');
final loginUrl = Uri.parse('${getConfig().backendUrl}/login');
final launched = await launchUrl(
loginUrl,
mode: LaunchMode.externalApplication,
@ -127,8 +129,22 @@ class _LoginPageState extends State<LoginPage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Column(
body: Column(
children: [
if (widget.sessionExpired)
MaterialBanner(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
content: const Text(
"Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
style: TextStyle(color: Colors.white),
),
backgroundColor: Colors.orange.shade800,
leading: const Icon(Icons.warning_amber_rounded, color: Colors.white),
actions: [const SizedBox.shrink()],
),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Padding(
@ -160,17 +176,30 @@ class _LoginPageState extends State<LoginPage> {
children: [
Padding(
padding: const EdgeInsets.only(top: 15, bottom: 15),
child: _isLoading
? const Column(
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Warte auf Login...'),
],
)
: OutlinedButton(
onPressed: _onPressLogin,
child: const Text("Anmelden mit Holzleitner Login"),
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, authState) {
final isBusy =
_isLoading || authState is Authenticating;
if (!isBusy) {
return OutlinedButton(
onPressed: _onPressLogin,
child: const Text(
"Anmelden mit Holzleitner Login",
),
);
}
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> {
),
],
),
),
),
],
),
);
}

View 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());
}
}
}

View 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});
}

View 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 {}

View File

@ -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),
],
),
),
),
);
}
}

View File

@ -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),
),
],
);
},
);
}
}

View 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"),
),
),
),
],
),
),
),
);
}
}

View 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,
),
),
],
),
),
],
);
},
);
}
}

View File

@ -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!);
}
}

View File

@ -1,4 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/exceptions.dart';
import 'package:hl_lieferservice/feature/cars/repository/cars_repository.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
@ -10,8 +13,9 @@ import 'cars_state.dart';
class CarsBloc extends Bloc<CarEvents, CarsState> {
CarsRepository repository;
OperationBloc opBloc;
AuthBloc authBloc;
CarsBloc({required this.repository, required this.opBloc})
CarsBloc({required this.repository, required this.opBloc, required this.authBloc})
: super(CarsInitial()) {
on<CarAdd>(_carAdd);
on<CarEdit>(_carEdit);
@ -19,12 +23,27 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
on<CarLoad>(_carLoad);
}
void _handleError(Object e, String fallbackMessage) {
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
} else {
opBloc.add(FailOperation(message: fallbackMessage));
}
}
Future<void> _carLoad(CarLoad event, Emitter<CarsState> emit) async {
// Skip the API call if cars are already loaded and no force-refresh requested.
if (state is CarsLoaded && !event.force) return;
try {
emit(CarsLoading());
List<Car> cars = await repository.getAll(event.teamId);
emit(CarsLoaded(cars: cars, teamId: event.teamId));
} catch (e) {
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
return;
}
emit(CarsLoadingFailed());
}
}
@ -32,8 +51,8 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
Future<void> _carAdd(CarAdd event, Emitter<CarsState> emit) async {
final currentState = state;
opBloc.add(StartOperation());
try {
opBloc.add(LoadOperation());
Car newCar = await repository.add(event.teamId, event.plate);
if (currentState is CarsLoaded) {
@ -46,15 +65,15 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
opBloc.add(FinishOperation(message: "Auto erfolgreich hinzugefügt"));
} catch (e) {
opBloc.add(FailOperation(message: "Fehler beim Hinzufügen eines Autos"));
_handleError(e, "Fehler beim Hinzufügen eines Autos");
}
}
Future<void> _carEdit(CarEdit event, Emitter<CarsState> emit) async {
final currentState = state;
opBloc.add(StartOperation());
try {
opBloc.add(LoadOperation());
await repository.edit(event.teamId, event.newCar);
if (currentState is CarsLoaded) {
@ -74,15 +93,15 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
opBloc.add(FinishOperation(message: "Auto erfolgreich editiert"));
} catch (e) {
opBloc.add(FailOperation(message: "Fehler beim Editieren des Autos"));
_handleError(e, "Fehler beim Editieren des Autos");
}
}
Future<void> _carDelete(CarDelete event, Emitter<CarsState> emit) async {
final currentState = state;
opBloc.add(StartOperation());
try {
opBloc.add(LoadOperation());
await repository.delete(event.carId, event.teamId);
if (currentState is CarsLoaded) {
@ -100,7 +119,7 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
opBloc.add(FinishOperation(message: "Auto erfolgreich gelöscht"));
} catch (e) {
opBloc.add(FailOperation(message: "Fehler beim Löschen des Autos"));
_handleError(e, "Fehler beim Löschen des Autos");
}
}
}

View File

@ -5,7 +5,11 @@ abstract class CarEvents {}
class CarLoad extends CarEvents {
String teamId;
CarLoad({required this.teamId});
/// If [force] is true the API is always called, bypassing the cache.
/// Use this for pull-to-refresh. Defaults to false.
bool force;
CarLoad({required this.teamId, this.force = false});
}
class CarEdit extends CarEvents {

View 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,
});
}

View File

@ -5,6 +5,7 @@ import 'car_dialog.dart';
class CarCard extends StatelessWidget {
final Car car;
final bool isSelected;
final Function(Car car) onDelete;
final Function(Car car, String newName) onEdit;
@ -13,11 +14,20 @@ class CarCard extends StatelessWidget {
required this.car,
required this.onEdit,
required this.onDelete,
this.isSelected = false,
});
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).primaryColor;
return Card(
color: isSelected ? primary.withValues(alpha: 0.08) : null,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: isSelected
? BorderSide(color: primary, width: 2)
: BorderSide.none,
),
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
@ -30,13 +40,30 @@ class CarCard extends StatelessWidget {
child: Icon(
Icons.local_shipping,
size: 32,
color: Theme.of(context).primaryColor,
color: primary,
),
),
Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(car.plate),
child: Text(
car.plate,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
),
if (isSelected)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(
Icons.check_circle,
size: 20,
color: primary,
semanticLabel: 'Aktuell ausgewählt',
),
),
],
),

View File

@ -6,9 +6,11 @@ import 'package:flutter/material.dart';
class CarManagementOverview extends StatefulWidget {
final List<Car> cars;
final int? selectedCarId;
final Function(String plate) onAdd;
final Function(String id) onDelete;
final Function(String id, String plate) onEdit;
final Future<void> Function() onRefresh;
const CarManagementOverview({
super.key,
@ -16,6 +18,8 @@ class CarManagementOverview extends StatefulWidget {
required this.onDelete,
required this.onEdit,
required this.onAdd,
required this.onRefresh,
this.selectedCarId,
});
@override
@ -40,30 +44,14 @@ class _CarManagementOverviewState extends State<CarManagementOverview> {
widget.onEdit(car.id.toString(), newName);
}
Widget _buildCarOverview() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.all(15), child: Text("Fahrzeuge", style: Theme.of(context).textTheme.headlineSmall),),
Expanded(child: Padding(
padding: const EdgeInsets.all(10),
child: widget.cars.isEmpty ? const Center(child: Text("keine Fahrzeuge vorhanden")) : ListView.builder(
itemBuilder:
(context, index) => CarCard(
car: widget.cars[index],
onEdit: _editCar,
onDelete: _removeCar,
),
itemCount: widget.cars.length,
),
))
],
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Fahrzeuge"),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
),
floatingActionButton: FloatingActionButton(
onPressed: _addCar,
backgroundColor: Theme.of(context).primaryColor,
@ -72,7 +60,34 @@ class _CarManagementOverviewState extends State<CarManagementOverview> {
color: Theme.of(context).colorScheme.onSecondary,
),
),
body: _buildCarOverview(),
body: RefreshIndicator(
onRefresh: widget.onRefresh,
child: widget.cars.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(10),
children: const [
SizedBox(
height: 200,
child: Center(child: Text("keine Fahrzeuge vorhanden")),
),
],
)
: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(10),
itemCount: widget.cars.length,
itemBuilder: (context, index) {
final car = widget.cars[index];
return CarCard(
car: car,
isSelected: widget.selectedCarId == car.id,
onEdit: _editCar,
onDelete: _removeCar,
);
},
),
),
);
}
}

View File

@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_event.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
@ -37,7 +39,55 @@ class _CarManagementPageState extends State<CarManagementPage> {
);
}
Future<void> _refresh() async {
context.read<CarsBloc>().add(CarLoad(teamId: _authState.user.number, force: true));
}
void _remove(String id) {
final carId = int.parse(id);
final carSelectState = context.read<CarSelectBloc>().state;
if (carSelectState is CarSelectComplete &&
carSelectState.selectedCar.id == carId) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Dieses Fahrzeug ist aktuell ausgewählt und kann nicht gelöscht werden. "
"Bitte wähle zuerst ein anderes Fahrzeug aus.",
),
duration: Duration(seconds: 4),
),
);
return;
}
final tourState = context.read<TourBloc>().state;
if (tourState is! TourLoaded) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Die Tourdaten sind noch nicht verfügbar. "
"Bitte versuche es in Kürze erneut.",
),
duration: Duration(seconds: 4),
),
);
return;
}
if (tourState.tour.hasUndeliveredLoadedArticles(carId)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Dieses Fahrzeug hat noch geladene Artikel, die nicht ausgeliefert wurden. "
"Bitte schließe alle offenen Lieferungen ab, bevor du das Fahrzeug löschst.",
),
duration: Duration(seconds: 4),
),
);
return;
}
context.read<CarsBloc>().add(
CarDelete(carId: id, teamId: _authState.user.number),
);
@ -68,11 +118,20 @@ class _CarManagementPageState extends State<CarManagementPage> {
}
if (state is CarsLoaded) {
return CarManagementOverview(
cars: state.cars,
onEdit: _edit,
onAdd: _add,
onDelete: _remove,
return BlocBuilder<CarSelectBloc, CarSelectState>(
builder: (context, selectState) {
final int? selectedCarId = selectState is CarSelectComplete
? selectState.selectedCar.id
: null;
return CarManagementOverview(
cars: state.cars,
selectedCarId: selectedCarId,
onEdit: _edit,
onAdd: _add,
onDelete: _remove,
onRefresh: _refresh,
);
},
);
}

View File

@ -9,16 +9,20 @@ import 'package:hl_lieferservice/feature/delivery/overview/service/distance_serv
import 'package:hl_lieferservice/feature/delivery/overview/service/reorder_service.dart';
import 'package:hl_lieferservice/feature/delivery/util.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/exceptions.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
import 'package:rxdart/rxdart.dart';
class TourBloc extends Bloc<TourEvent, TourState> {
OperationBloc opBloc;
AuthBloc authBloc;
TourRepository tourRepository;
StreamSubscription? _combinedSubscription;
TourBloc({required this.opBloc, required this.tourRepository})
TourBloc({required this.opBloc, required this.authBloc, required this.tourRepository})
: super(TourInitial()) {
_combinedSubscription = CombineLatestStream.combine2(
tourRepository.tour,
@ -39,6 +43,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
on<AssignCarEvent>(_assignCar);
on<IncrementArticleScanAmount>(_increment);
on<ScanArticleEvent>(_scan);
on<ScanComponentEvent>(_scanComponent);
on<HoldDeliveryEvent>(_holdDelivery);
on<CancelDeliveryEvent>(_cancelDelivery);
on<ReactivateDeliveryEvent>(_reactivateDelivery);
@ -61,17 +66,24 @@ class TourBloc extends Bloc<TourEvent, TourState> {
@override
Future<void> close() {
_combinedSubscription?.cancel();
return super.close();
}
void _handleError(Object e, String fallbackMessage) {
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
} else {
opBloc.add(FailOperation(message: fallbackMessage));
}
}
void _setArticleAmount(
SetArticleAmountEvent event,
Emitter<TourState> emit,
) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
await tourRepository.setArticleAmount(
event.deliveryId,
@ -79,15 +91,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
event.amount,
event.reason,
);
opBloc.add(FinishOperation());
} catch (e, st) {
opBloc.add(
FailOperation(message: "Fehler beim Ändern der Menge des Artikels"),
);
debugPrint("$e");
debugPrint("$st");
debugPrint("$e $st");
_handleError(e, "Fehler beim Ändern der Menge des Artikels");
}
}
}
@ -126,35 +133,23 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Emitter<TourState> emit,
) async {
Map<String, double> distances = {};
opBloc.add(LoadOperation());
emit(TourRequestingDistances(tour: event.tour, payments: event.payments));
for (final delivery in event.tour.deliveries) {
try {
distances[delivery.id] = await DistanceService.getDistanceByRoad(
delivery.customer.address.toString(),
);
} catch (e,st) {
} catch (e, st) {
debugPrint("Fehler beim Laden der Distanz: $e");
debugPrint("$st");
// set the distance to none in order to handle the error case
// afterwards for that specific delivery
distances[delivery.id] = double.nan;
}
}
opBloc.add(FinishOperation());
// If an error occurred, then the distances will be empty
// If the distances are empty then they shouldn't be displayed
add(
RequestSortingInformationEvent(
tour: event.tour,
payments: event.payments,
distances: distances,
),
);
final currentState = state;
if (currentState is TourLoaded) {
emit(currentState.copyWith(distances: distances));
}
}
void _requestSortingInformation(
@ -215,9 +210,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
tour: event.tour,
paymentOptions: event.payments,
sortingInformation: container,
distances: event.distances,
),
);
add(RequestDeliveryDistanceEvent(tour: event.tour));
}
void _updated(TourUpdated event, Emitter<TourState> emit) {
@ -233,14 +229,14 @@ class TourBloc extends Bloc<TourEvent, TourState> {
paymentOptions: payments,
distances: Map<String, double>.from(currentState.distances ?? {}),
sortingInformation: currentState.sortingInformation,
pendingScanRequests: currentState.pendingScanRequests,
),
);
}
// Download distances if tour has previously fetched by API
if (currentState is TourLoading) {
add(
RequestDeliveryDistanceEvent(tour: tour.copyWith(), payments: payments),
RequestSortingInformationEvent(tour: tour.copyWith(), payments: payments),
);
}
}
@ -251,17 +247,13 @@ class TourBloc extends Bloc<TourEvent, TourState> {
) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
await tourRepository.reactivateDelivery(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Zurückstellen der Lieferung"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Zurückstellen der Lieferung");
}
}
}
@ -269,17 +261,13 @@ class TourBloc extends Bloc<TourEvent, TourState> {
void _holdDelivery(HoldDeliveryEvent event, Emitter<TourState> emit) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
await tourRepository.holdDelivery(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Zurückstellen der Lieferung"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Zurückstellen der Lieferung");
}
}
}
@ -290,26 +278,69 @@ class TourBloc extends Bloc<TourEvent, TourState> {
) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
await tourRepository.cancelDelivery(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Zurückstellen der Lieferung"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Stornieren der Lieferung");
}
}
}
void _bumpPendingScans(Emitter<TourState> emit, int delta) {
final currentState = state;
if (currentState is TourLoaded) {
final next = (currentState.pendingScanRequests + delta).clamp(0, 1 << 30);
emit(currentState.copyWith(pendingScanRequests: next));
}
}
void _scanComponent(
ScanComponentEvent event,
Emitter<TourState> emit,
) async {
final currentState = state;
if (currentState is TourLoaded) {
_bumpPendingScans(emit, 1);
try {
switch (await tourRepository.scanComponent(
event.deliveryId,
event.carId,
event.componentArticleNumber,
)) {
case ScanResult.scanned:
opBloc.add(FinishOperation(message: 'Komponente gescannt'));
break;
case ScanResult.alreadyScanned:
opBloc.add(
FailOperation(message: 'Komponente wurde bereits gescannt'),
);
break;
case ScanResult.notFound:
opBloc.add(
FailOperation(
message: 'Komponente ist für keine Lieferung vorgesehen',
),
);
break;
}
} catch (e, st) {
debugPrint("FEHLER beim Scannen einer Komponente: $e $st");
_handleError(e, "Fehler beim Scannen der Komponente");
} finally {
_bumpPendingScans(emit, -1);
}
}
}
void _scan(ScanArticleEvent event, Emitter<TourState> emit) async {
final currentState = state;
opBloc.add(LoadOperation());
if (currentState is TourLoaded) {
_bumpPendingScans(emit, 1);
try {
switch (await tourRepository.scanArticle(
event.deliveryId,
@ -333,9 +364,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
break;
}
} catch (e, st) {
debugPrint("FEHLER beim Scannen eines Artikels: $e");
debugPrint("$st");
opBloc.add(FailOperation(message: "Fehler beim Scannen des Artikels"));
debugPrint("FEHLER beim Scannen eines Artikels: $e $st");
_handleError(e, "Fehler beim Scannen des Artikels");
} finally {
_bumpPendingScans(emit, -1);
}
}
}
@ -347,17 +379,18 @@ class TourBloc extends Bloc<TourEvent, TourState> {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
_bumpPendingScans(emit, 1);
try {
await tourRepository.scanArticle(
event.deliveryId,
event.carId,
event.internalArticleId,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(st.toString());
opBloc.add(FailOperation(message: "Fehler beim Scannen des Artikels"));
debugPrint("$e $st");
_handleError(e, "Fehler beim Scannen des Artikels");
} finally {
_bumpPendingScans(emit, -1);
}
}
}
@ -365,34 +398,29 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Future<void> _assignCar(AssignCarEvent event, Emitter<TourState> emit) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
await tourRepository.assignCar(event.deliveryId, event.carId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(st.toString());
opBloc.add(
FailOperation(message: "Fehler beim Zuweisen des Fahrzeugs"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Zuweisen des Fahrzeugs");
}
}
}
Future<void> _load(LoadTour event, Emitter<TourState> emit) async {
opBloc.add(LoadOperation());
try {
emit(TourLoading());
await tourRepository.loadTourOfToday(event.teamId);
await tourRepository.loadPaymentOptions();
opBloc.add(FinishOperation());
} catch (e) {
// go to the error state in order to give the user the chance
// to reload if necessary.
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
return;
}
emit(TourLoadingFailed());
opBloc.add(
FailOperation(message: "Fehler beim Laden der heutigen Fahrten"),
);
opBloc.add(FailOperation(message: "Fehler beim Laden der heutigen Fahrten"));
}
}
@ -401,9 +429,9 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Emitter<TourState> emit,
) async {
final currentState = state;
opBloc.add(LoadOperation());
if (currentState is TourLoaded) {
opBloc.add(StartOperation(message: "Lieferung wird abgeschlossen…"));
try {
await tourRepository.uploadDriverSignature(
event.deliveryId,
@ -415,11 +443,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
);
await tourRepository.finishDelivery(event.deliveryId);
opBloc.add(FinishOperation());
opBloc.add(FinishOperation(message: "Lieferung abgeschlossen"));
} catch (e, st) {
opBloc.add(FailOperation(message: "Failed to update delivery"));
debugPrint(st.toString());
debugPrint("$e $st");
_handleError(e, "Fehler beim Abschließen der Lieferung");
}
}
}
@ -428,15 +455,13 @@ class TourBloc extends Bloc<TourEvent, TourState> {
UpdateSelectedPaymentMethodEvent event,
Emitter<TourState> emit,
) async {
opBloc.add(StartOperation());
try {
opBloc.add(LoadOperation());
await tourRepository.updatePayment(event.deliveryId, event.payment);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(st.toString());
opBloc.add(
FailOperation(message: "Fehler beim Aktualisieren des Betrags"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Aktualisieren des Betrags");
}
}
@ -444,8 +469,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
UpdateDeliveryOptionEvent event,
Emitter<TourState> emit,
) async {
opBloc.add(StartOperation());
try {
opBloc.add(LoadOperation());
await tourRepository.updateOption(
event.deliveryId,
event.key,
@ -453,10 +478,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Aktualisieren der Optionen"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Aktualisieren der Optionen");
}
}
@ -464,10 +487,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
UpdateDiscountEvent event,
Emitter<TourState> emit,
) async {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
opBloc.add(FinishOperation());
await tourRepository.updateDiscount(
event.deliveryId,
event.reason,
@ -475,15 +496,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(
"Fehler beim Hinzufügen eins Discounts zur Lieferung: ${event.deliveryId}:",
);
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Hinzufügen des Discounts: $e"),
);
debugPrint("Fehler beim Aktualisieren des Discounts: $e $st");
_handleError(e, "Fehler beim Aktualisieren des Discounts");
}
}
@ -491,51 +505,33 @@ class TourBloc extends Bloc<TourEvent, TourState> {
RemoveDiscountEvent event,
Emitter<TourState> emit,
) async {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
await tourRepository.removeDiscount(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(
"Fehler beim Löschen des Discounts der Lieferung: ${event.deliveryId}:",
);
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Löschen des Discounts: $e"),
);
debugPrint("Fehler beim Löschen des Discounts: $e $st");
_handleError(e, "Fehler beim Löschen des Discounts");
}
}
void _addDiscount(AddDiscountEvent event, Emitter<TourState> emit) async {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
await tourRepository.addDiscount(
event.deliveryId,
event.reason,
event.value,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(
"Fehler beim Hinzufügen eins Discounts zur Lieferung: ${event.deliveryId}:",
);
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Hinzufügen des Discounts: $e"),
);
debugPrint("Fehler beim Hinzufügen des Discounts: $e $st");
_handleError(e, "Fehler beim Hinzufügen des Discounts");
}
}
void _unscan(UnscanArticleEvent event, Emitter<TourState> emit) async {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
await tourRepository.unscan(
event.deliveryId,
@ -545,27 +541,19 @@ class TourBloc extends Bloc<TourEvent, TourState> {
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Unscan des Artikels: ${event.articleId}:");
debugPrint("$e");
debugPrint("$st");
opBloc.add(FailOperation(message: "Fehler beim Unscan des Artikels: $e"));
debugPrint("Fehler beim Unscan des Artikels ${event.articleId}: $e $st");
_handleError(e, "Fehler beim Unscan des Artikels");
}
}
void _resetAmount(ResetScanAmountEvent event, Emitter<TourState> emit) async {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
await tourRepository.resetScan(event.articleId, event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Unscan des Artikels: ${event.articleId}:");
debugPrint("$e");
debugPrint("$st");
opBloc.add(FailOperation(message: "Fehler beim Zurücksetzen: $e"));
debugPrint("Fehler beim Zurücksetzen Artikel ${event.articleId}: $e $st");
_handleError(e, "Fehler beim Zurücksetzen");
}
}
}

View File

@ -15,20 +15,17 @@ class LoadTour extends TourEvent {
class RequestDeliveryDistanceEvent extends TourEvent {
Tour tour;
List<Payment> payments;
RequestDeliveryDistanceEvent({required this.tour, required this.payments});
RequestDeliveryDistanceEvent({required this.tour});
}
class RequestSortingInformationEvent extends TourEvent {
Tour tour;
List<Payment> payments;
Map<String, double>? distances;
RequestSortingInformationEvent({
required this.tour,
required this.payments,
this.distances,
});
}
@ -90,6 +87,20 @@ class ScanArticleEvent extends TourEvent {
String carId;
}
/// Scan a single BOM component. The server call for the parent article is
/// deferred until *all* components are fully scanned.
class ScanComponentEvent extends TourEvent {
ScanComponentEvent({
required this.componentArticleNumber,
required this.carId,
required this.deliveryId,
});
String componentArticleNumber;
String deliveryId;
String carId;
}
class CancelDeliveryEvent extends TourEvent {
String deliveryId;

View File

@ -8,49 +8,38 @@ class TourLoading extends TourState {}
class TourLoadingFailed extends TourState {}
class TourRequestingDistances extends TourState {
Tour tour;
List<Payment> payments;
TourRequestingDistances({required this.tour, required this.payments});
}
class TourRequestingSortingInformation extends TourState {
Tour tour;
Map<String, double>? distances;
List<Payment> paymentOptions;
TourRequestingSortingInformation({
required this.tour,
this.distances,
required this.paymentOptions,
});
}
class TourLoaded extends TourState {
Tour tour;
Map<String, double>? distances;
List<Payment> paymentOptions;
Map<String, List<String>> sortingInformation;
/// Number of scan-related server requests currently in flight. Drives the
/// inline indicator on the scanner widget. Using a counter (not bool) lets
/// rapid-fire scans coexist without one prematurely clearing the indicator.
int pendingScanRequests;
TourLoaded({
required this.tour,
this.distances,
required this.paymentOptions,
required this.sortingInformation
required this.sortingInformation,
this.pendingScanRequests = 0,
});
TourLoaded copyWith({
Tour? tour,
Map<String, double>? distances,
List<Payment>? paymentOptions,
Map<String, List<String>>? sortingInformation
Map<String, List<String>>? sortingInformation,
int? pendingScanRequests,
}) {
return TourLoaded(
tour: tour ?? this.tour,
distances: distances ?? this.distances,
paymentOptions: paymentOptions ?? this.paymentOptions,
sortingInformation: sortingInformation ?? this.sortingInformation
sortingInformation: sortingInformation ?? this.sortingInformation,
pendingScanRequests: pendingScanRequests ?? this.pendingScanRequests,
);
}
}

View File

@ -3,6 +3,9 @@ import 'dart:typed_data';
import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/exceptions.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
import 'package:rxdart/rxdart.dart';
@ -15,6 +18,7 @@ import 'package:hl_lieferservice/feature/delivery/detail/repository/note_reposit
class NoteBloc extends Bloc<NoteEvent, NoteState> {
final NoteRepository repository;
final OperationBloc opBloc;
final AuthBloc authBloc;
final String deliveryId;
StreamSubscription? _combinedSubscription;
@ -22,6 +26,7 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
NoteBloc({
required this.repository,
required this.opBloc,
required this.authBloc,
required this.deliveryId,
}) : super(NoteInitial()) {
_combinedSubscription = CombineLatestStream.combine3(
@ -60,10 +65,17 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
@override
Future<void> close() {
_combinedSubscription?.cancel();
return super.close();
}
void _handleError(Object e, String fallbackMessage) {
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
} else {
opBloc.add(FailOperation(message: fallbackMessage));
}
}
Future<void> _dataUpdated(DataUpdated event, Emitter<NoteState> emit) async {
emit(
NoteLoaded(
@ -82,96 +94,79 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
RemoveImageNote event,
Emitter<NoteState> emit,
) async {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
await repository.deleteImage(event.deliveryId, event.objectId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Löschen des Bildes: $e");
debugPrint(st.toString());
opBloc.add(FailOperation(message: e.toString()));
debugPrint("Fehler beim Löschen des Bildes: $e $st");
_handleError(e, "Fehler beim Löschen des Bildes");
}
}
Future<void> _upload(AddImageNote event, Emitter<NoteState> emit) async {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
Uint8List imageBytes = await event.file.readAsBytes();
await repository.addImage(event.deliveryId, imageBytes);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen des Bildes: $e");
debugPrint(st.toString());
opBloc.add(FailOperation(message: e.toString()));
debugPrint("Fehler beim Hinzufügen des Bildes: $e $st");
_handleError(e, "Fehler beim Hinzufügen des Bildes");
}
}
Future<void> _load(LoadNote event, Emitter<NoteState> emit) async {
if (state is NoteLoaded || state is NoteLoading) {
return;
}
emit.call(NoteLoading());
try {
await repository.loadNotes(event.delivery.id);
await repository.loadTemplates();
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Herunterladen der Notizen: $e");
debugPrint(st.toString());
opBloc.add(
FailOperation(message: "Notizen konnten nicht heruntergeladen werden."),
);
debugPrint("Fehler beim Herunterladen der Notizen: $e $st");
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
return;
}
opBloc.add(FailOperation(message: "Notizen konnten nicht heruntergeladen werden."));
emit.call(NoteLoadingFailed());
}
}
Future<void> _add(AddNote event, Emitter<NoteState> emit) async {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
await repository.addNote(event.deliveryId, event.note);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen der Notiz: $e");
debugPrint(st.toString());
opBloc.add(FailOperation(message: e.toString()));
debugPrint("Fehler beim Hinzufügen der Notiz: $e $st");
_handleError(e, "Fehler beim Hinzufügen der Notiz");
}
}
Future<void> _edit(EditNote event, Emitter<NoteState> emit) async {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
await repository.editNote(event.noteId, event.content);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen der Notiz: $e");
debugPrint(st.toString());
opBloc.add(FailOperation(message: e.toString()));
debugPrint("Fehler beim Editieren der Notiz: $e $st");
_handleError(e, "Fehler beim Editieren der Notiz");
}
}
Future<void> _remove(RemoveNote event, Emitter<NoteState> emit) async {
opBloc.add(LoadOperation());
opBloc.add(StartOperation());
try {
await repository.deleteNote(event.noteId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen der Notiz: $e");
debugPrint(st.toString());
opBloc.add(
FailOperation(message: "Notizen konnte nicht gelöscht werden."),
);
debugPrint("Fehler beim Löschen der Notiz: $e $st");
_handleError(e, "Notiz konnte nicht gelöscht werden");
}
}
}

View File

@ -91,8 +91,10 @@ class _ResetArticleAmountDialogState extends State<ResetArticleAmountDialog> {
children: [
const Text("Wollen Sie die entfernten Artikel wieder hinzufügen?"),
!widget.article.scannable ? _amountSelection() : Container(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
Wrap(
spacing: 10,
runSpacing: 8,
alignment: WrapAlignment.spaceEvenly,
children: [
FilledButton(
onPressed: _reset,

View File

@ -154,8 +154,10 @@ class _ArticleUnscanDialogState extends State<ArticleUnscanDialog> {
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
Wrap(
spacing: 10,
runSpacing: 8,
alignment: WrapAlignment.spaceAround,
children: [
FilledButton(
onPressed: isValidText ? _unscan : null,

View File

@ -142,66 +142,70 @@ class _DeliveryDetailState extends State<DeliveryDetail> {
}
Widget _stepsNavigation(Delivery delivery) {
return SizedBox(
width: double.infinity,
height: 90,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton(
onPressed: _step == 0 ? null : _clickBack,
child: const Text("zurück"),
),
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"),
return SafeArea(
top: false,
child: SizedBox(
width: double.infinity,
height: 90,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton(
onPressed: _step == 0 ? null : _clickBack,
child: const Text("zurück"),
),
),
],
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
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Auslieferungsdetails")),
body: BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
final currentState = state;
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
Delivery? delivery;
if (state is TourLoaded) {
delivery = state.tour.deliveries.firstWhere(
(d) => d.id == widget.deliveryId,
);
}
if (currentState is TourLoaded) {
Delivery delivery = currentState.tour.deliveries.firstWhere(
(delivery) => delivery.id == widget.deliveryId,
);
return Column(
children: [
_stepInfo(),
const Divider(),
Expanded(
child:
StepFactory().make(_step, delivery) ??
_stepMissingWarning(),
return Scaffold(
appBar: AppBar(title: const Text("Auslieferungsdetails")),
body: delivery == null
? const Center(child: CircularProgressIndicator())
: Column(
children: [
_stepInfo(),
const Divider(),
Expanded(
child:
StepFactory().make(_step, delivery) ??
_stepMissingWarning(),
),
],
),
_stepsNavigation(delivery),
],
);
}
return const Center(child: CircularProgressIndicator());
},
),
bottomNavigationBar:
delivery == null ? null : _stepsNavigation(delivery),
);
},
);
}
}

View File

@ -195,17 +195,16 @@ class _DeliveryDiscountState extends State<DeliveryDiscount> {
},
),
),
Row(
Wrap(
spacing: 10,
runSpacing: 8,
children: [
Padding(
padding: const EdgeInsets.only(right: 10),
child: FilledButton(
onPressed:
!_isReasonEmpty && _discountValue > 0
? _updateValues
: null,
child: const Text("Speichern"),
),
FilledButton(
onPressed:
!_isReasonEmpty && _discountValue > 0
? _updateValues
: null,
child: const Text("Speichern"),
),
OutlinedButton(
onPressed: _discountValue > 0 && widget.discount != null ? _resetValues : null,

View File

@ -9,6 +9,8 @@ import 'package:hl_lieferservice/model/delivery.dart';
import 'package:intl/intl.dart';
import 'package:signature/signature.dart';
enum _SigningPhase { customerAcceptance, customerSignature, driverSignature }
class SignatureView extends StatefulWidget {
const SignatureView({
super.key,
@ -43,33 +45,11 @@ class _SignatureViewState extends State<SignatureView> {
exportBackgroundColor: Colors.white,
);
bool _isDriverSigning = false;
bool _customerAccepted = false;
bool _noteAccepted = false;
bool _notesEmpty = true;
bool _isCustomerSignatureEmpty = true;
bool _isDriverSignatureEmpty = true;
_SigningPhase _phase = _SigningPhase.customerAcceptance;
@override
void initState() {
super.initState();
_customerController.addListener(() {
if (_isCustomerSignatureEmpty != _customerController.isEmpty) {
setState(() {
_isCustomerSignatureEmpty = _customerController.isEmpty;
});
}
});
_driverController.addListener(() {
if (_isDriverSignatureEmpty != _driverController.isEmpty) {
setState(() {
_isDriverSignatureEmpty = _driverController.isEmpty;
});
}
});
context.read<NoteBloc>().add(LoadNote(delivery: widget.delivery));
}
@ -80,14 +60,88 @@ class _SignatureViewState extends State<SignatureView> {
super.dispose();
}
Widget _signatureField() {
return Signature(
controller: _isDriverSigning ? _driverController : _customerController,
backgroundColor: Colors.white,
void _onAcceptanceDone() {
setState(() => _phase = _SigningPhase.customerSignature);
}
void _onCustomerSigned() {
setState(() => _phase = _SigningPhase.driverSignature);
}
Future<void> _onDriverSigned() async {
widget.onSigned(
(await _customerController.toPngBytes())!,
(await _driverController.toPngBytes())!,
);
}
Widget _notes() {
@override
Widget build(BuildContext context) {
return switch (_phase) {
_SigningPhase.customerAcceptance => _AcceptanceStep(
onContinue: _onAcceptanceDone,
),
_SigningPhase.customerSignature => _SignaturePadStep(
controller: _customerController,
delivery: widget.delivery,
appBarTitle: "Unterschrift des Kunden",
buttonLabel: "Weiter",
onContinue: _onCustomerSigned,
),
_SigningPhase.driverSignature => _SignaturePadStep(
controller: _driverController,
delivery: widget.delivery,
appBarTitle: "Unterschrift des Fahrers",
buttonLabel: "Absenden",
onContinue: _onDriverSigned,
),
};
}
}
class _AcceptanceStep extends StatefulWidget {
const _AcceptanceStep({required this.onContinue});
final VoidCallback onContinue;
@override
State<_AcceptanceStep> createState() => _AcceptanceStepState();
}
class _AcceptanceStepState extends State<_AcceptanceStep> {
bool _customerAccepted = false;
bool _noteAccepted = false;
Widget _notesContent(NoteState noteState) {
if (noteState is! NoteLoaded) {
return const SizedBox(
width: double.infinity,
child: Center(child: CircularProgressIndicator()),
);
}
if (noteState.notes.isEmpty) {
return const SizedBox(
width: double.infinity,
child: Center(child: Text("Keine Notizen vorhanden")),
);
}
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return ListTile(
leading: const Icon(Icons.event_note_outlined),
title: Text(noteState.notes[index].content),
contentPadding: const EdgeInsets.all(20),
tileColor: Theme.of(context).colorScheme.onSecondary,
);
},
separatorBuilder: (context, index) => const Divider(height: 0),
itemCount: noteState.notes.length,
);
}
Widget _notes(NoteState noteState) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -98,163 +152,171 @@ class _SignatureViewState extends State<SignatureView> {
style: Theme.of(context).textTheme.headlineSmall,
),
),
BlocConsumer<NoteBloc, NoteState>(
listener: (context, state) {
final current = state;
if (current is NoteLoaded) {
setState(() {
_notesEmpty = current.notes.isEmpty;
});
}
if (current is NoteLoadedBase) {
setState(() {
_notesEmpty = current.notes.isEmpty;
});
}
},
builder: (context, state) {
final current = state;
if (current is NoteLoaded) {
if (current.notes.isEmpty) {
return const SizedBox(
width: double.infinity,
child: Center(child: Text("Keine Notizen vorhanden")),
);
}
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return ListTile(
leading: const Icon(Icons.event_note_outlined),
title: Text(current.notes[index].content),
contentPadding: const EdgeInsets.all(20),
tileColor: Theme.of(context).colorScheme.onSecondary,
);
},
separatorBuilder: (context, index) => const Divider(height: 0),
itemCount: current.notes.length,
);
}
return const SizedBox(
width: double.infinity,
child: Center(child: CircularProgressIndicator()),
);
},
),
_notesContent(noteState),
const Padding(padding: EdgeInsets.only(top: 25), child: Divider()),
],
);
}
Widget _customerCheckboxes() {
return !_isDriverSigning
? Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 25, bottom: 0),
child: _notes(),
),
Padding(
padding: const EdgeInsets.only(top: 25.0, bottom: 0),
child: Row(
children: [
Checkbox(
value: _noteAccepted,
onChanged:
_notesEmpty
@override
Widget build(BuildContext context) {
return BlocBuilder<NoteBloc, NoteState>(
builder: (context, noteState) {
final notesEmpty = switch (noteState) {
NoteLoadedBase(notes: final ns) => ns.isEmpty,
_ => true,
};
final isButtonEnabled =
_customerAccepted && (_noteAccepted || notesEmpty);
return Scaffold(
appBar: AppBar(title: const Text("Unterschrift des Kunden")),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: ListView(
children: [
Padding(
padding: const EdgeInsets.only(top: 25, bottom: 0),
child: _notes(noteState),
),
Padding(
padding: const EdgeInsets.only(top: 25.0, bottom: 0),
child: Row(
children: [
Checkbox(
value: _noteAccepted,
onChanged: notesEmpty
? null
: (value) {
setState(() {
_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,
setState(() {
_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,
),
),
),
],
),
],
),
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(() {
_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;
});
},
),
),
],
),
),
],
)
: Container();
),
);
},
);
}
}
class _SignaturePadStep extends StatefulWidget {
const _SignaturePadStep({
required this.controller,
required this.delivery,
required this.appBarTitle,
required this.buttonLabel,
required this.onContinue,
});
final SignatureController controller;
final Delivery delivery;
final String appBarTitle;
final String buttonLabel;
final VoidCallback onContinue;
@override
State<_SignaturePadStep> createState() => _SignaturePadStepState();
}
class _SignaturePadStepState extends State<_SignaturePadStep> {
bool _isEmpty = true;
late final VoidCallback _listener;
@override
void initState() {
super.initState();
_isEmpty = widget.controller.isEmpty;
_listener = () {
if (_isEmpty != widget.controller.isEmpty) {
setState(() {
_isEmpty = widget.controller.isEmpty;
});
}
};
widget.controller.addListener(_listener);
}
@override
void dispose() {
widget.controller.removeListener(_listener);
super.dispose();
}
@override
Widget build(BuildContext context) {
String formattedDate = DateFormat("dd.MM.yyyy").format(DateTime.now());
bool isButtonEnabled;
if (!_isDriverSigning) {
isButtonEnabled =
_customerAccepted &&
(_noteAccepted || _notesEmpty) &&
!_isCustomerSignatureEmpty;
} else {
isButtonEnabled = !_isDriverSignatureEmpty;
}
final formattedDate = DateFormat("dd.MM.yyyy").format(DateTime.now());
return Scaffold(
appBar: AppBar(
title:
!_isDriverSigning
? const Text("Unterschrift des Kunden")
: const Text("Unterschrift des Fahrers"),
),
appBar: AppBar(title: Text(widget.appBarTitle)),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: ListView(
children: [
SizedBox(
width: double.infinity,
height:
MediaQuery.of(context).size.height *
(_isDriverSigning ? 0.75 : 0.5),
height: MediaQuery.of(context).size.height * 0.75,
child: DecoratedBox(
decoration: const BoxDecoration(color: Colors.white),
child: Padding(
@ -272,7 +334,12 @@ class _SignatureViewState extends State<SignatureView> {
fontWeight: FontWeight.bold,
),
),
Expanded(child: _signatureField()),
Expanded(
child: Signature(
controller: widget.controller,
backgroundColor: Colors.white,
),
),
],
),
),
@ -285,36 +352,22 @@ class _SignatureViewState extends State<SignatureView> {
),
),
),
_customerCheckboxes(),
Padding(
padding: const EdgeInsets.only(top: 25.0, bottom: 25.0),
child: Center(
child: FilledButton(
onPressed:
isButtonEnabled
? () async {
if (!_isDriverSigning) {
setState(() {
_isDriverSigning = true;
});
} else {
widget.onSigned(
(await _customerController.toPngBytes())!,
(await _driverController.toPngBytes())!,
);
}
}
: null,
child:
!_isDriverSigning
? const Text("Weiter")
: const Text("Absenden"),
),
),
),
],
),
),
bottomNavigationBar: SafeArea(
top: false,
child: SizedBox(
width: double.infinity,
height: 90,
child: Center(
child: FilledButton(
onPressed: _isEmpty ? null : widget.onContinue,
child: Text(widget.buttonLabel),
),
),
),
),
);
}
}

View File

@ -51,6 +51,10 @@ class _NoteAddDialogState extends State<NoteAddDialog> {
@override
Widget build(BuildContext context) {
return Dialog(
// Default Dialog.insetPadding eats 80 dp horizontally on phones, leaving
// too little room for two side-by-side buttons on narrow devices like
// the Samsung A16F. Shrinking the inset gives back ~64 dp.
insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24),
child: SizedBox(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height * 0.6,
@ -115,8 +119,9 @@ class _NoteAddDialogState extends State<NoteAddDialog> {
maxLines: 10,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
Wrap(
spacing: 10,
runSpacing: 8,
children: [
FilledButton(
onPressed:
@ -126,15 +131,12 @@ class _NoteAddDialogState extends State<NoteAddDialog> {
: null,
child: const Text("Hinzufügen"),
),
Padding(
padding: const EdgeInsets.only(left: 10.0),
child: OutlinedButton(
onPressed: () {
_noteController.clear();
_noteSelectionController.clear();
},
child: const Text("Zurücksetzen"),
),
OutlinedButton(
onPressed: () {
_noteController.clear();
_noteSelectionController.clear();
},
child: const Text("Zurücksetzen"),
),
],
),

View File

@ -8,6 +8,8 @@ import 'package:url_launcher/url_launcher.dart';
import '../../../bloc/tour_bloc.dart';
import '../../../bloc/tour_state.dart';
enum _StatusAction { hold, cancel, reactivate }
class DeliveryStepInfo extends StatefulWidget {
final Delivery delivery;
@ -39,76 +41,73 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
Widget _deliveryStatusChangeActions() {
List<Widget> actions = [];
Widget _statusOverflow() {
final state = widget.delivery.state;
final List<PopupMenuEntry<_StatusAction>> entries;
if (widget.delivery.state == DeliveryState.ongoing) {
actions = [
Column(
children: [
IconButton(
onPressed: () {
context.read<TourBloc>().add(
HoldDeliveryEvent(deliveryId: widget.delivery.id),
);
Navigator.of(context).pop();
},
icon: Icon(
Icons.change_circle,
color: Colors.orangeAccent,
size: 42,
),
),
Text("Zurückstellen"),
],
if (state == DeliveryState.ongoing) {
entries = const [
PopupMenuItem(
value: _StatusAction.hold,
child: Row(
children: [
Icon(Icons.change_circle, color: Colors.orangeAccent),
SizedBox(width: 12),
Text("Zurückstellen"),
],
),
),
Column(
children: [
IconButton(
onPressed: () {
context.read<TourBloc>().add(
CancelDeliveryEvent(deliveryId: widget.delivery.id),
);
Navigator.of(context).pop();
},
//style: IconButton.styleFrom(backgroundColor: Colors.red),
icon: Icon(Icons.cancel, color: Colors.red, size: 42),
),
Text("Abbrechen"),
],
PopupMenuItem(
value: _StatusAction.cancel,
child: Row(
children: [
Icon(Icons.cancel, color: Colors.red),
SizedBox(width: 12),
Text("Abbrechen"),
],
),
),
];
} else {
entries = const [
PopupMenuItem(
value: _StatusAction.reactivate,
child: Row(
children: [
Icon(Icons.published_with_changes, color: Colors.blueAccent),
SizedBox(width: 12),
Text("Reaktivieren"),
],
),
),
];
}
if (widget.delivery.state == DeliveryState.canceled ||
widget.delivery.state == DeliveryState.onhold ||
widget.delivery.state == DeliveryState.finished) {
actions = [
Column(
children: [
IconButton(
onPressed: () {
context.read<TourBloc>().add(
ReactivateDeliveryEvent(deliveryId: widget.delivery.id),
);
},
icon: Icon(
Icons.published_with_changes,
color: Colors.blueAccent,
size: 42
),
),
Text("Reaktivieren"),
],
),
];
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: actions,
return PopupMenuButton<_StatusAction>(
icon: const Icon(Icons.more_vert),
tooltip: "Status ändern",
itemBuilder: (context) => entries,
onSelected: (action) {
switch (action) {
case _StatusAction.hold:
context.read<TourBloc>().add(
HoldDeliveryEvent(deliveryId: widget.delivery.id),
);
Navigator.of(context).pop();
break;
case _StatusAction.cancel:
context.read<TourBloc>().add(
CancelDeliveryEvent(deliveryId: widget.delivery.id),
);
Navigator.of(context).pop();
break;
case _StatusAction.reactivate:
context.read<TourBloc>().add(
ReactivateDeliveryEvent(deliveryId: widget.delivery.id),
);
break;
}
},
);
}
@ -119,55 +118,46 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
color: Theme.of(context).colorScheme.onSecondary,
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Column(
children: [
IconButton.filled(
onPressed:
widget.delivery.contactPerson?.phoneNumber != null
? () async {
Expanded(
child: Builder(
builder: (context) {
final phone = widget.delivery.contactPerson?.phoneNumber;
final bool hasPhone = phone != null && phone.isNotEmpty;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filled(
onPressed: hasPhone
? () async {
await launchUrl(
Uri(
scheme: "tel",
path:
widget
.delivery
.contactPerson
?.phoneNumber!,
),
Uri(scheme: "tel", path: phone),
);
}
: null,
icon: Icon(Icons.phone),
),
Text("Anrufen"),
],
),
Column(
children: [
IconButton.filled(
onPressed: () {
_launchMapsUrl("google");
},
icon: Icon(Icons.map_outlined),
),
Text("Google Maps"),
],
),
],
: null,
icon: const Icon(Icons.phone),
),
const Text("Anrufen"),
],
);
},
),
),
const Padding(
padding: EdgeInsets.only(top: 10, bottom: 10),
child: Divider(),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filled(
onPressed: () => _launchMapsUrl("google"),
icon: const Icon(Icons.map_outlined),
),
const Text("Google Maps"),
],
),
),
_deliveryStatusChangeActions(),
_statusOverflow(),
],
),
),
@ -176,6 +166,16 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
}
Widget _customerInformation() {
final phone = widget.delivery.contactPerson?.phoneNumber;
final String phoneText = (phone != null && phone.isNotEmpty)
? phone
: "keine Nummer angegeben";
final email = widget.delivery.customer.email;
final String emailText = (email != null && email.isNotEmpty)
? email
: "keine E-Mail angegeben";
return SizedBox(
width: double.infinity,
child: Card(
@ -228,9 +228,24 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
Icon(Icons.phone, color: Theme.of(context).primaryColor),
Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(
widget.delivery.contactPerson?.phoneNumber.toString() ??
"",
child: Text(phoneText),
),
],
),
),
Padding(
padding: const EdgeInsets.only(top: 15),
child: Row(
children: [
Icon(Icons.mail, color: Theme.of(context).primaryColor),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(
emailText,
overflow: TextOverflow.ellipsis,
),
),
),
],
@ -275,28 +290,66 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
);
}
Widget _deliveryAgreements() {
Widget _agreementsAndDesiredTime() {
String agreements = "keine Vereinbarungen getroffen!";
if (widget.delivery.specialAgreements != null &&
widget.delivery.specialAgreements != "") {
agreements = widget.delivery.specialAgreements!;
}
final desiredTime = widget.delivery.desiredTime;
final bool hasDesiredTime = desiredTime != null && desiredTime.isNotEmpty;
final primary = Theme.of(context).primaryColor;
return Card(
color: Theme.of(context).colorScheme.onSecondary,
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: EdgeInsets.all(15),
child: Icon(
Icons.warning,
color: Theme.of(context).primaryColor,
size: 28,
if (hasDesiredTime) ...[
Row(
children: [
Padding(
padding: const EdgeInsets.all(15),
child: Icon(Icons.schedule, color: primary, size: 28),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Wunschtermin",
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Text(
desiredTime,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: primary,
),
),
],
),
),
],
),
const Divider(height: 24),
],
Row(
children: [
Padding(
padding: const EdgeInsets.all(15),
child: Icon(Icons.warning, color: primary, size: 28),
),
Expanded(child: Text(agreements)),
],
),
Expanded(child: Text(agreements)),
],
),
),
@ -330,7 +383,7 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
),
Padding(
padding: const EdgeInsets.only(top: 10),
child: _deliveryAgreements(),
child: _agreementsAndDesiredTime(),
),
Padding(

View File

@ -275,10 +275,14 @@ class NoteService {
LocalDocuFrameConfiguration config = getConfig();
return urls.map((url) async {
return (await http.get(
final response = await http.get(
Uri.parse("${config.backendUrl}$url"),
headers: getSessionOrThrow(),
)).bodyBytes;
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
return response.bodyBytes;
}).toList();
} catch (e, st) {
debugPrint("An error occured:");

View File

@ -1,80 +1,86 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/model/delivery.dart' show DeliveryState;
import 'package:hl_lieferservice/model/tour.dart';
import 'package:intl/intl.dart';
class DeliveryInfo extends StatelessWidget {
final Tour tour;
final int? selectedCarId;
const DeliveryInfo({super.key, required this.tour});
const DeliveryInfo({super.key, required this.tour, this.selectedCarId});
@override
Widget build(BuildContext context) {
String date = DateFormat("dd.MM.yyyy").format(tour.date);
String amountDeliveries = tour.deliveries.length.toString();
final String date = DateFormat("dd.MM.yyyy").format(tour.date);
final relevantDeliveries = selectedCarId != null
? tour.deliveries.where((d) => d.carId == selectedCarId).toList()
: tour.deliveries;
final total = relevantDeliveries.length;
final done = relevantDeliveries
.where((d) => d.state == DeliveryState.finished)
.length;
final progress = total > 0 ? done / total : 0.0;
final allDone = total > 0 && done == total;
return Padding(
padding: const EdgeInsets.all(10),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
"Informationen",
style: Theme.of(context).textTheme.headlineSmall,
),
),
SizedBox(
width: double.infinity,
child: Card(
color: Theme.of(context).colorScheme.onSecondary,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 15),
child: SizedBox(
width: double.infinity,
child: Card(
color: Theme.of(context).colorScheme.onSecondary,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.calendar_month),
Padding(
padding: const EdgeInsets.only(left: 5),
child: Text("Datum"),
),
],
const Icon(Icons.calendar_month),
const Padding(
padding: EdgeInsets.only(left: 5),
child: Text("Datum"),
),
Text(date),
],
),
Padding(
padding: const EdgeInsets.only(top: 15),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.local_shipping_outlined),
Padding(
padding: const EdgeInsets.only(left: 5),
child: Text("Lieferungen"),
),
],
),
Text(amountDeliveries),
],
),
),
Text(date),
],
),
),
const SizedBox(height: 15),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.local_shipping_outlined),
const Padding(
padding: EdgeInsets.only(left: 5),
child: Text("Lieferungen"),
),
],
),
Text("$done / $total"),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
minHeight: 6,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
allDone ? Colors.green : Theme.of(context).primaryColor,
),
),
),
],
),
),
],
),
),
);
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart';
@ -10,68 +11,143 @@ import '../../detail/service/notes_service.dart';
class DeliveryListItem extends StatelessWidget {
final Delivery delivery;
final double distance;
final double? distance;
const DeliveryListItem({
super.key,
required this.delivery,
required this.distance,
this.distance,
});
Widget _leading(BuildContext context) {
if (delivery.state == DeliveryState.finished) {
return Icon(Icons.check_circle, color: Colors.green);
}
if (delivery.state == DeliveryState.canceled) {
return Icon(Icons.cancel_rounded, color: Colors.red);
}
if (delivery.state == DeliveryState.onhold) {
return Icon(Icons.pause_circle, color: Colors.orange);
}
return Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Icon(Icons.location_on, color: Theme.of(context).primaryColor),
Text("${distance.toStringAsFixed(2)}km"),
],
);
}
void _goToDelivery(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder:
(context) => BlocProvider(
create:
(context) => NoteBloc(
deliveryId: delivery.id,
opBloc: context.read<OperationBloc>(),
repository: NoteRepository(
service: NoteService(),
),
),
child: DeliveryDetail(deliveryId: delivery.id),
),
builder: (context) => BlocProvider(
create: (context) => NoteBloc(
deliveryId: delivery.id,
opBloc: context.read<OperationBloc>(),
authBloc: context.read<AuthBloc>(),
repository: NoteRepository(service: NoteService()),
),
child: DeliveryDetail(deliveryId: delivery.id),
),
),
);
}
(Color, Color, IconData, String) _stateStyle(BuildContext context) {
switch (delivery.state) {
case DeliveryState.finished:
return (
Colors.green.withValues(alpha: 0.07),
Colors.green.withValues(alpha: 0.35),
Icons.check_circle_rounded,
"Abgeschlossen",
);
case DeliveryState.canceled:
return (
Colors.red.withValues(alpha: 0.07),
Colors.red.withValues(alpha: 0.35),
Icons.cancel_rounded,
"Storniert",
);
case DeliveryState.onhold:
return (
Colors.orange.withValues(alpha: 0.07),
Colors.orange.withValues(alpha: 0.35),
Icons.pause_circle_rounded,
"Pausiert",
);
case DeliveryState.ongoing:
final distanceLabel = distance != null && !distance!.isNaN
? "${distance!.toStringAsFixed(1)} km"
: "";
return (
Theme.of(context).colorScheme.surfaceContainerLow,
Colors.transparent,
Icons.local_shipping_outlined,
distanceLabel,
);
}
}
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(
delivery.customer.name,
style: Theme.of(context).textTheme.titleMedium,
final (cardColor, borderColor, icon, statusLabel) = _stateStyle(context);
final isOngoing = delivery.state == DeliveryState.ongoing;
final iconColor = switch (delivery.state) {
DeliveryState.finished => Colors.green,
DeliveryState.canceled => Colors.red,
DeliveryState.onhold => Colors.orange,
DeliveryState.ongoing => Theme.of(context).primaryColor,
};
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: borderColor),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _goToDelivery(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(icon, color: iconColor, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
delivery.customer.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: isOngoing ? null : iconColor,
),
),
const SizedBox(height: 2),
Text(
delivery.customer.address.toString(),
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
statusLabel,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isOngoing
? Theme.of(context).colorScheme.onSurfaceVariant
: iconColor,
),
),
const SizedBox(height: 4),
Icon(
Icons.arrow_forward_ios,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
],
),
),
),
leading: _leading(context),
tileColor: Theme.of(context).colorScheme.surfaceContainerHigh,
subtitle: Text(delivery.customer.address.toString()),
trailing: Icon(Icons.arrow_forward_ios),
onTap: () => _goToDelivery(context),
);
}
}

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/delivery/overview/model/sorting_information.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart';
import 'package:hl_lieferservice/model/delivery.dart';
@ -43,7 +43,7 @@ class _DeliveryListState extends State<DeliveryList> {
return DeliveryListItem(
delivery: delivery,
distance: distances[delivery.id] ?? 0.0,
distance: distances[delivery.id],
);
},
itemCount: sortingInformation.length,
@ -56,79 +56,66 @@ class _DeliveryListState extends State<DeliveryList> {
builder: (context, state) {
final currentState = state;
if (currentState is TourLoaded) {
List<Delivery> deliveries =
currentState.tour.deliveries
.where(
(delivery) =>
delivery.carId == widget.selectedCarId &&
delivery.allArticlesScanned() &&
delivery.state != DeliveryState.finished,
)
.toList();
if (widget.sortType == SortType.custom) {
return _showCustomSortedList(
currentState.tour.deliveries,
currentState.sortingInformation[widget.selectedCarId.toString()] ?? [],
currentState.distances ?? {},
);
}
List<Delivery> finishedDeliveries =
currentState.tour.deliveries
.where(
(delivery) =>
delivery.state == DeliveryState.finished &&
delivery.carId == widget.selectedCarId,
)
.toList();
final allDeliveries = currentState.tour.deliveries
.where((d) => d.carId == widget.selectedCarId)
.toList();
if (deliveries.isEmpty) {
if (allDeliveries.isEmpty) {
return ListView(
physics: NeverScrollableScrollPhysics(),
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
children: [
Center(child: const Text("Keine Auslieferungen gefunden")),
children: const [
Center(child: Text("Keine Auslieferungen gefunden")),
],
);
}
final ongoing = allDeliveries
.where((d) => d.state == DeliveryState.ongoing)
.toList();
final nonOngoing = allDeliveries
.where((d) => d.state != DeliveryState.ongoing)
.toList();
int Function(Delivery, Delivery) comparator;
switch (widget.sortType) {
case SortType.custom:
return _showCustomSortedList(
currentState.tour.deliveries,
currentState.sortingInformation[widget.selectedCarId.toString()] ?? [],
currentState.distances ?? {},
);
case SortType.nameAsc:
deliveries.sort(
(a, b) => a.customer.name.compareTo(b.customer.name),
);
comparator = (a, b) => a.customer.name.compareTo(b.customer.name);
break;
case SortType.nameDesc:
deliveries.sort(
(a, b) => b.customer.name.compareTo(a.customer.name),
);
comparator = (a, b) => b.customer.name.compareTo(a.customer.name);
break;
case SortType.distance:
deliveries.sort(
(a, b) => (currentState.distances![a.id] ?? 0.0).compareTo(
currentState.distances![b.id] ?? 0.0,
),
);
comparator = (a, b) =>
(currentState.distances?[a.id] ?? 0.0)
.compareTo(currentState.distances?[b.id] ?? 0.0);
break;
default:
comparator = (a, b) => a.customer.name.compareTo(b.customer.name);
}
//deliveries.addAll(finishedDeliveries);
ongoing.sort(comparator);
nonOngoing.sort(comparator);
return ListView.separated(
separatorBuilder: (context, index) => const Divider(height: 0),
final sorted = [...ongoing, ...nonOngoing];
return ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
Delivery delivery = deliveries[index];
return DeliveryListItem(
delivery: delivery,
distance: currentState.distances?[delivery.id] ?? 0.0,
);
},
itemCount: deliveries.length,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 8),
itemCount: sorted.length,
itemBuilder: (context, index) => DeliveryListItem(
delivery: sorted[index],
distance: currentState.distances?[sorted[index].id],
),
);
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_info.dart';
@ -16,11 +18,9 @@ class DeliveryOverview extends StatefulWidget {
const DeliveryOverview({
super.key,
required this.tour,
required this.distances,
});
final Tour tour;
final Map<String, double> distances;
@override
State<StatefulWidget> createState() => _DeliveryOverviewState();
@ -34,8 +34,14 @@ class _DeliveryOverviewState extends State<DeliveryOverview> {
void initState() {
super.initState();
// Select the first car for initialization
_selectedCarId = widget.tour.driver.cars.firstOrNull?.id;
// Pre-select today's car from the daily car selection.
// Falls back to the first available car if no selection exists.
final carSelectState = context.read<CarSelectBloc>().state;
if (carSelectState is CarSelectComplete) {
_selectedCarId = carSelectState.selectedCar.id;
} else {
_selectedCarId = widget.tour.driver.cars.firstOrNull?.id;
}
_sortType = SortType.nameAsc;
}
@ -44,54 +50,6 @@ class _DeliveryOverviewState extends State<DeliveryOverview> {
context.read<TourBloc>().add(LoadTour(teamId: state.user.number));
}
Widget _carSelection() {
return SizedBox(
width: double.infinity,
height: 50,
child: ListView(
scrollDirection: Axis.horizontal,
children:
widget.tour.driver.cars.map((car) {
Color? backgroundColor;
Color? iconColor = Theme.of(context).primaryColor;
Color? textColor;
if (_selectedCarId == car.id) {
backgroundColor = Theme.of(context).primaryColor;
textColor = Theme.of(context).colorScheme.onSecondary;
iconColor = Theme.of(context).colorScheme.onSecondary;
}
return Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () {
setState(() {
_selectedCarId = car.id;
});
},
child: Chip(
backgroundColor: backgroundColor,
label: Row(
children: [
Icon(Icons.local_shipping, color: iconColor, size: 20),
Padding(
padding: const EdgeInsets.only(left: 5),
child: Text(
car.plate,
style: TextStyle(color: textColor, fontSize: 12),
),
),
],
),
),
),
);
}).toList(),
),
);
}
/// Highlight the text of the active sorting type.
TextStyle? _popupItemTextStyle() {
return TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold);
@ -99,17 +57,23 @@ class _DeliveryOverviewState extends State<DeliveryOverview> {
@override
Widget build(BuildContext context) {
return RefreshIndicator(
return BlocListener<CarSelectBloc, CarSelectState>(
listener: (context, carState) {
if (carState is CarSelectComplete) {
setState(() => _selectedCarId = carState.selectedCar.id);
}
},
child: RefreshIndicator(
onRefresh: _loadTour,
child: ListView(
//crossAxisAlignment: CrossAxisAlignment.start,
children: [
DeliveryInfo(tour: widget.tour),
DeliveryInfo(tour: widget.tour, selectedCarId: _selectedCarId),
Padding(
padding: const EdgeInsets.only(
left: 10,
right: 10,
top: 15,
top: 0,
bottom: 10,
),
child: Row(
@ -191,16 +155,13 @@ class _DeliveryOverviewState extends State<DeliveryOverview> {
],
),
),
Padding(
padding: const EdgeInsets.only(left: 10, right: 10, bottom: 20),
child: _carSelection(),
),
DeliveryList(
selectedCarId: _selectedCarId,
sortType: _sortType,
),
],
),
),
);
}
}

View File

@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart';
import 'package:hl_lieferservice/model/tour.dart';
import '../../bloc/tour_bloc.dart';
import '../../bloc/tour_state.dart';
@ -14,27 +16,90 @@ class DeliveryOverviewPage extends StatefulWidget {
}
class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
Widget _buildOverviewWithBanner({
required Tour tour,
required String bannerText,
}) {
return Column(
children: [
Material(
color: Colors.amber.shade100,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 12),
Expanded(child: Text(bannerText)),
],
),
),
),
Expanded(
child: DeliveryOverview(tour: tour),
),
],
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is TourLoaded) {
final currentState = state;
final carState = context.watch<CarSelectBloc>().state;
return Center(
child: DeliveryOverview(
tour: currentState.tour,
distances: currentState.distances ?? {},
return Scaffold(
appBar: AppBar(
title: const Text("Auslieferung"),
centerTitle: false,
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
actions: [
if (carState is CarSelectComplete)
Padding(
padding: const EdgeInsets.only(right: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.local_shipping,
color: Theme.of(context).colorScheme.onSecondary,
size: 20,
),
const SizedBox(width: 6),
Text(
carState.selectedCar.plate,
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
],
),
body: BlocBuilder<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) {
return DeliveryLoadingFailedPage();
}
if (state is TourLoadingFailed) {
return DeliveryLoadingFailedPage();
}
return Container();
},
return const Center(child: CircularProgressIndicator());
},
),
);
}
}

View File

@ -80,6 +80,8 @@ class TourRepository {
if (article.scannedAmount < article.amount) {
article.scannedAmount += 1;
delivery.carId = int.tryParse(carId) ?? delivery.carId;
await service.assignCar(deliveryId, carId);
_tourStream.add(tour);
return ScanResult.scanned;
} else {
@ -90,6 +92,48 @@ class TourRepository {
}
}
/// Scan a single BOM component locally. The server-side `scanArticle` call
/// for the parent article is deferred until **every** component of the
/// parent is fully scanned — only then does the parent count as loaded.
Future<ScanResult> scanComponent(
String deliveryId,
String carId,
String componentArticleNumber,
) async {
if (!_tourStream.hasValue) {
throw TourNotFoundException();
}
final tour = _tourStream.value!;
final delivery = tour.deliveries.firstWhere(
(d) => d.id == deliveryId,
);
// Locate the parent article and the matching component.
final parentArticle = delivery.findParentOfComponent(
componentArticleNumber,
);
if (parentArticle == null) return ScanResult.notFound;
final component = parentArticle.findComponent(componentArticleNumber)!;
if (component.isFullyScanned) return ScanResult.alreadyScanned;
// ── Local-only increment ──
component.scannedAmount += 1;
// ── When every component is done, sync the parent with the server ──
if (parentArticle.isFullyScanned) {
await service.scanArticle(parentArticle.internalId.toString());
parentArticle.scannedAmount += 1;
delivery.carId = int.tryParse(carId) ?? delivery.carId;
await service.assignCar(deliveryId, carId);
}
_tourStream.add(tour);
return ScanResult.scanned;
}
Future<void> unscan(
String deliveryId,
String articleId,

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:hl_lieferservice/dto/delivery_response.dart';
import 'package:hl_lieferservice/dto/delivery_update.dart';
import 'package:hl_lieferservice/dto/delivery_update_response.dart';
@ -271,10 +272,24 @@ class TourService {
Future<BasicResponseDTO> finishDelivery(String deliveryId) async {
try {
// ISO-8601 mit T-Separator: sprachunabhaengig fuer SQL-Server datetime.
// ('yyyy-MM-dd HH:mm:ss' OHNE T wird unter DATEFORMAT=DMY (DE) als YDM
// geparst und schlaegt fuer Tag > 12 fehl.)
// ERPframe arbeitet mit lokaler Zeit -> bewusst keine UTC-Konvertierung.
final String deliveredAt = DateFormat(
"yyyy-MM-dd'T'HH:mm:ss",
).format(DateTime.now());
var headers = {"Content-Type": "application/json"};
headers.addAll(getSessionOrThrow());
var response = await post(
urlBuilder("_web_finishDelivery"),
headers: getSessionOrThrow(),
body: {"delivery_id": deliveryId},
headers: headers,
body: jsonEncode({
"delivery_id": deliveryId,
"delivered_at": deliveredAt,
}),
);
if (response.statusCode == HttpStatus.unauthorized) {

File diff suppressed because it is too large Load Diff

View File

@ -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();
},
);
}
}

View File

@ -53,11 +53,8 @@ class _BarcodeScannerWidgetState extends State<BarcodeScannerWidget> {
@override
Widget build(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
final scannerHeight = screenHeight / 4;
return Container(
height: scannerHeight,
height: 150,
decoration: BoxDecoration(
border: Border.all(
color: _isDetected ? Colors.green : Colors.grey,

View File

@ -122,10 +122,9 @@ class _SettingsPage extends State<SettingsPage> {
],
),
appBar: AppBar(
title: Text(
"Einstellungen",
style: Theme.of(context).textTheme.headlineMedium,
),
title: const Text("Einstellungen"),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
),
);
}

View File

@ -1,5 +1,7 @@
import 'package:hl_lieferservice/dto/article.dart';
import 'component.dart';
class Article {
Article({
required this.name,
@ -11,13 +13,21 @@ class Article {
required this.scannable,
required this.scannedAmount,
required this.scannedRemovedAmount,
required this.isParent,
this.components = const [],
this.scannedDate,
this.removeNoteId
this.removeNoteId,
this.warehouseNr,
this.warehouseName,
});
final String name;
final String articleNumber;
final int internalId;
final bool isParent;
final List<Component> components;
final String? warehouseNr;
final String? warehouseName;
int amount;
double price;
@ -36,7 +46,35 @@ class Article {
return price * scannedAmount * ((100 + tax) / 100);
}
/// Whether this article is fully scanned.
///
/// For parent articles (Stückliste): delegates to components — all must be
/// individually scanned. For regular articles: the classic amount check.
bool get isFullyScanned {
if (isParent && components.isNotEmpty) {
return components.every((c) => c.isFullyScanned);
}
return scannedAmount + scannedRemovedAmount >= amount;
}
/// Find a component by its article number, or `null` if none matches.
Component? findComponent(String articleNumber) {
for (final c in components) {
if (c.articleNumber == articleNumber) return c;
}
return null;
}
/// Whether this article *or* any of its components carries [articleNumber].
bool hasArticleNumber(String articleNumber) {
if (this.articleNumber == articleNumber) return true;
return components.any((c) => c.articleNumber == articleNumber);
}
bool unscanned() {
if (isParent && components.isNotEmpty) {
return components.every((c) => c.scannedAmount == 0);
}
return scannedAmount == 0;
}
@ -58,6 +96,11 @@ class Article {
price: double.parse(dto.price == "" ? "0.0" : dto.price),
scannable: dto.scannable,
tax: double.parse(dto.taxRate == "" ? "19" : dto.taxRate),
isParent: dto.isParent,
components: dto.components?.map(Component.fromDTO).toList() ?? [],
warehouseNr: dto.warehouseNr?.isEmpty ?? true ? null : dto.warehouseNr,
warehouseName:
dto.warehouseName?.isEmpty ?? true ? null : dto.warehouseName,
);
}
}

34
lib/model/component.dart Normal file
View 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,
);
}
}

View File

@ -3,12 +3,17 @@ import 'package:hl_lieferservice/dto/customer.dart';
import 'address.dart';
class Customer {
const Customer({required this.name, required this.address});
const Customer({required this.name, required this.address, this.email});
final String name;
final Address address;
final String? email;
factory Customer.fromDTO(CustomerDTO dto) {
return Customer(name: dto.name, address: Address.fromDTO(dto.address));
return Customer(
name: dto.name,
address: Address.fromDTO(dto.address),
email: dto.eMail,
);
}
}

View File

@ -295,29 +295,42 @@ class Delivery implements Comparable<Delivery> {
List<Article> getDeliveredArticles() {
return articles
.where(
(article) => article.scannedAmount > 0 || !article.scannable,
)
.where((article) {
if (!article.scannable) return true;
if (article.isParent && article.components.isNotEmpty) {
return article.isFullyScanned;
}
return article.scannedAmount > 0;
})
.toList();
}
bool containsArticle(String articleNr) {
return articles.any((article) => article.articleNumber == articleNr);
return articles.any((article) => article.hasArticleNumber(articleNr));
}
Article getArticle(String nr) {
return articles.firstWhere((article) => article.articleNumber == nr);
}
/// Find the parent article whose BOM contains [componentArticleNr].
Article? findParentOfComponent(String componentArticleNr) {
for (final article in articles) {
if (article.isParent &&
article.findComponent(componentArticleNr) != null) {
return article;
}
}
return null;
}
List<Article> getScannableArticles() {
return articles.where((article) => article.scannable).toList();
}
bool allArticlesScanned() {
return getScannableArticles().every(
(article) =>
article.amount ==
article.scannedAmount + article.scannedRemovedAmount,
(article) => article.isFullyScanned,
);
}

View File

@ -60,6 +60,23 @@ class Tour {
.length;
}
/// Returns true if the car still has loaded articles assigned to a delivery
/// that has not been finished yet. Scannable articles count when their
/// effective scanned amount (scanned minus removed) is positive; non-scannable
/// articles count when their target amount is greater than zero.
bool hasUndeliveredLoadedArticles(int carId) {
return deliveries.any((delivery) {
if (delivery.carId != carId) return false;
if (delivery.state == DeliveryState.finished) return false;
return delivery.articles.any((article) {
if (article.scannable) {
return article.scannedAmount > article.scannedRemovedAmount;
}
return article.amount > 0;
});
});
}
Tour copyWith({
DateTime? date,
String? discountArticleNumber,

View File

@ -4,7 +4,13 @@ import 'package:hl_lieferservice/bloc/app_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/presentation/login_enforcer.dart';
import 'package:hl_lieferservice/feature/authentication/service/userinfo.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/presentation/car_selection_enforcer.dart';
import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart';
import 'package:hl_lieferservice/feature/cars/repository/cars_repository.dart';
import 'package:hl_lieferservice/feature/cars/service/cars_service.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/repository/tour_repository.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
@ -46,33 +52,50 @@ class _DeliveryAppState extends State<DeliveryApp> {
create:
(context) => TourBloc(
opBloc: context.read<OperationBloc>(),
authBloc: context.read<AuthBloc>(),
tourRepository: TourRepository(
service: TourService(),
),
),
),
BlocProvider(
create: (context) =>
CarSelectBloc(repository: CarSelectionRepository()),
),
BlocProvider(
create: (context) => CarsBloc(
repository: CarsRepository(service: CarService()),
opBloc: context.read<OperationBloc>(),
authBloc: context.read<AuthBloc>(),
),
),
],
child: MaterialApp(
home: OperationViewEnforcer(
child: BlocBuilder<AppBloc, AppState>(
builder: (context, state) {
if (state is AppConfigLoading) {
return Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
// Wrap the Navigator (not just the home route) so the loading
// overlay covers every pushed route — DeliveryDetail, Cars,
// dialogs, etc. — not only the initial home tree.
builder: (context, child) =>
OperationViewEnforcer(child: child ?? const SizedBox.shrink()),
home: BlocBuilder<AppBloc, AppState>(
builder: (context, state) {
if (state is AppConfigLoading) {
return Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (state is AppConfigLoadingFailed) {
return Scaffold(body: Center(child: Text(state.message)));
}
if (state is AppConfigLoadingFailed) {
return Scaffold(body: Center(child: Text(state.message)));
}
if (state is AppConfigLoaded) {
return LoginEnforcer(child: Home());
}
if (state is AppConfigLoaded) {
return LoginEnforcer(
child: CarSelectionEnforcer(child: Home()),
);
}
return Container();
},
),
return Container();
},
),
routes: {"/cars": (context) => CarManagementPage()},
),

View File

@ -7,15 +7,12 @@ import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview_page.dart';
import 'package:hl_lieferservice/feature/scan/presentation/scan_page.dart';
import 'package:hl_lieferservice/widget/app_bar.dart';
import 'package:hl_lieferservice/feature/car_selection/presentation/selected_car_bar.dart';
import 'package:hl_lieferservice/feature/settings/presentation/settings_page.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_state.dart';
import 'package:hl_lieferservice/widget/navigation_bar/presentation/navigation_bar.dart';
import '../../../feature/cars/bloc/cars_bloc.dart';
import '../../../feature/cars/repository/cars_repository.dart';
import '../../../feature/cars/service/cars_service.dart';
import '../../operations/bloc/operation_bloc.dart';
class Home extends StatefulWidget {
const Home({super.key});
@ -44,14 +41,11 @@ class _HomeState extends State<Home> {
}
if (index == 2) {
return BlocProvider(
create:
(context) => CarsBloc(
repository: CarsRepository(service: CarService()),
opBloc: context.read<OperationBloc>(),
),
child: CarManagementPage(),
);
return CarManagementPage();
}
if (index == 3) {
return SettingsPage();
}
return Container();
@ -64,12 +58,14 @@ class _HomeState extends State<Home> {
final currentState = state as NavigationInfo;
return Scaffold(
appBar: PreferredSize(
preferredSize: Size.fromHeight(kToolbarHeight),
child: CustomAppBar(),
),
body: _buildPage(currentState.navigationIndex),
bottomNavigationBar: AppNavigationBar(),
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
SelectedCarBar(),
AppNavigationBar(),
],
),
);
},
);

View File

@ -32,6 +32,11 @@ class _AppNavigationBarState extends State<AppNavigationBar> {
icon: Icon(Icons.local_shipping),
label: "Fahrzeuge",
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: "Einstellungen",
),
],
onDestinationSelected: (int index) {
context.read<NavigationBloc>().add(NavigateToIndex(index: index));

View File

@ -3,29 +3,79 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_state.dart';
class OperationBloc extends Bloc<OperationEvent, OperationState> {
/// Counts how many in-flight mutations want to show the blocking overlay.
/// Allows multiple parallel mutations without one prematurely closing the
/// overlay before the others complete.
int _inFlightCount = 0;
/// When the current overlay session began (set when [_inFlightCount]
/// transitions 0 → 1). Used to enforce [_minimumDisplayDuration].
DateTime? _overlayStartedAt;
/// Minimum time the overlay stays visible, even if the underlying request
/// completes faster. Prevents a "did anything happen?" UX where a sub-100 ms
/// roundtrip flashes the overlay for one frame.
static const Duration _minimumDisplayDuration = Duration(milliseconds: 350);
OperationBloc() : super(OperationIdle()) {
on<LoadOperation>(_loadOperation);
on<StartOperation>(_startOperation);
on<FailOperation>(_failOperation);
on<FinishOperation>(_finishOperation);
}
Future<void> _loadOperation(LoadOperation event, Emitter<OperationState> emit) async {
emit(OperationLoading());
Future<void> _startOperation(
StartOperation event,
Emitter<OperationState> emit,
) async {
if (_inFlightCount == 0) {
_overlayStartedAt = DateTime.now();
}
_inFlightCount += 1;
emit(OperationInProgress(message: event.message));
}
Future<void> _failOperation(FailOperation event, Emitter<OperationState> emit) async {
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));
await Future.delayed(const Duration(seconds: 5));
await Future.delayed(Duration(seconds: 5));
emit(OperationIdle());
if (_inFlightCount > 0) {
emit(OperationInProgress());
} else {
_overlayStartedAt = null;
emit(OperationIdle());
}
}
Future<void> _finishOperation(FinishOperation event, Emitter<OperationState> emit) async {
emit(OperationFinished(message: event.message));
await Future.delayed(Duration(seconds: 5));
emit(OperationIdle());
Future<void> _awaitMinimumOverlayDuration() async {
final startedAt = _overlayStartedAt;
if (startedAt == null) return;
final elapsed = DateTime.now().difference(startedAt);
if (elapsed < _minimumDisplayDuration) {
await Future.delayed(_minimumDisplayDuration - elapsed);
}
}
}
}

View File

@ -1,6 +1,10 @@
abstract class OperationEvent {}
class LoadOperation extends OperationEvent {}
class StartOperation extends OperationEvent {
String? message;
StartOperation({this.message});
}
class FailOperation extends OperationEvent {
String message;
@ -12,4 +16,4 @@ class FinishOperation extends OperationEvent {
String? message;
FinishOperation({this.message});
}
}

View File

@ -2,7 +2,11 @@ abstract class OperationState {}
class OperationIdle extends OperationState {}
class OperationLoading extends OperationState {}
class OperationInProgress extends OperationState {
String? message;
OperationInProgress({this.message});
}
class OperationFailed extends OperationState {
String message;
@ -14,4 +18,4 @@ class OperationFinished extends OperationState {
String? message;
OperationFinished({this.message});
}
}

View File

@ -4,47 +4,24 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import '../bloc/operation_state.dart';
/// OperationViewEnforcer
///
/// A view that encapsulates the functionality to react to asynchronous operations.
/// It is capable of showing a loading indicator while an operation is ongoing and it shows
/// a error message if the operation failed.
class OperationViewEnforcer extends StatefulWidget {
/// Listens to [OperationBloc] and shows:
/// - SnackBars for success and error messages.
/// - A blocking modal barrier with a spinner while a mutation is in flight,
/// so the user gets unambiguous "wait" feedback and cannot double-tap or
/// navigate away mid-request.
class OperationViewEnforcer extends StatelessWidget {
final Widget child;
const OperationViewEnforcer({super.key, required this.child});
@override
State<OperationViewEnforcer> createState() => _OperationViewEnforcerState();
}
class _OperationViewEnforcerState extends State<OperationViewEnforcer> {
OverlayEntry? _overlayEntry;
@override
void dispose() {
_overlayEntry?.remove();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<OperationBloc, OperationState>(
return BlocConsumer<OperationBloc, OperationState>(
listener: (context, state) {
if (state is OperationLoading) {
if (_overlayEntry == null) {
_overlayEntry = _createOverlayEntry(context);
Overlay.of(context).insert(_overlayEntry!);
}
} else {
_overlayEntry?.remove();
_overlayEntry = null;
}
if (state is OperationFinished) {
if (state.message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message!)),
);
}
if (state is OperationFinished && state.message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message!)),
);
}
if (state is OperationFailed) {
@ -53,20 +30,44 @@ class _OperationViewEnforcerState extends State<OperationViewEnforcer> {
);
}
},
child: widget.child,
);
}
builder: (context, state) {
final isInProgress = state is OperationInProgress;
final progressMessage =
isInProgress ? state.message : null;
OverlayEntry _createOverlayEntry(BuildContext context) {
return OverlayEntry(
builder: (context) => DecoratedBox(
decoration: const BoxDecoration(
color: Color.fromRGBO(128, 128, 128, 0.8),
),
child: const Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
return Stack(
children: [
child,
if (isInProgress)
PopScope(
canPop: false,
child: Stack(
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),
),
],
],
),
),
],
),
),
],
);
},
);
}
}
}

View File

@ -165,10 +165,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
@ -668,26 +668,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@ -1033,10 +1033,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.10"
timing:
dependency: transitive
description: