This commit is contained in:
Dennis Nemec
2026-04-28 13:03:09 +02:00
parent de8668c11a
commit 2470299a10
53 changed files with 2409 additions and 1433 deletions

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

@ -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,15 +15,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
: super(Unauthenticated()) {
on<SetAuthenticatedEvent>(_auth);
on<Logout>(_logout);
on<SessionExpiredEvent>(_sessionExpired);
}
Future<void> _auth(
SetAuthenticatedEvent event,
Emitter<AuthState> emit,
) async {
operationBloc.add(LoadOperation());
await Future.delayed(Duration(seconds: 5));
try {
debugPrint("Retrieve user information");
@ -31,7 +29,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
var state = Authenticated(sessionId: event.sessionId, user: response);
locator.registerSingleton<Authenticated>(state);
emit(state);
operationBloc.add(FinishOperation());
} catch (err, st) {
debugPrint("Failed to retrieve user information");
debugPrint(err.toString());
@ -46,6 +43,19 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
}
Future<void> _logout(Logout event, Emitter<AuthState> emit) async {
if (locator.isRegistered<Authenticated>()) {
locator.unregister<Authenticated>();
}
emit(Unauthenticated());
}
Future<void> _sessionExpired(
SessionExpiredEvent event,
Emitter<AuthState> emit,
) async {
if (locator.isRegistered<Authenticated>()) {
locator.unregister<Authenticated>();
}
emit(Unauthenticated(sessionExpired: true));
}
}

View File

@ -11,3 +11,5 @@ class Logout extends AuthEvent {
Logout({required this.username});
}
class SessionExpiredEvent extends AuthEvent {}

View File

@ -2,7 +2,11 @@ import 'package:hl_lieferservice/feature/authentication/model/user.dart';
abstract class AuthState {}
class Unauthenticated extends AuthState {}
class Unauthenticated extends AuthState {
final bool sessionExpired;
Unauthenticated({this.sessionExpired = false});
}
class Authenticated extends AuthState {
User user;
String sessionId;

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

@ -7,7 +7,9 @@ import 'package:url_launcher/url_launcher.dart';
import 'dart:async';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
final bool sessionExpired;
const LoginPage({super.key, this.sessionExpired = false});
@override
State<StatefulWidget> createState() => _LoginPageState();
@ -60,7 +62,7 @@ class _LoginPageState extends State<LoginPage> {
debugPrint("🔵 Opening browser to: http://localhost:3000/login");
final loginUrl = Uri.parse('http://100.72.100.33:3000/login');
final loginUrl = Uri.parse('http://192.168.1.9:3000/login');
final launched = await launchUrl(
loginUrl,
mode: LaunchMode.externalApplication,
@ -127,7 +129,21 @@ class _LoginPageState extends State<LoginPage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
body: Column(
children: [
if (widget.sessionExpired)
MaterialBanner(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
content: const Text(
"Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
style: TextStyle(color: Colors.white),
),
backgroundColor: Colors.orange.shade800,
leading: const Icon(Icons.warning_amber_rounded, color: Colors.white),
actions: [const SizedBox.shrink()],
),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
@ -180,6 +196,9 @@ class _LoginPageState extends State<LoginPage> {
],
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,88 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart';
import 'package:hl_lieferservice/feature/cars/model/selection.dart';
import 'package:hl_lieferservice/model/car.dart';
import 'events.dart';
import 'state.dart';
class CarSelectBloc extends Bloc<CarSelectEvent, CarSelectState> {
final CarSelectionRepository repository;
CarSelectBloc({required this.repository}) : super(CarSelectInitial()) {
on<CarSelectLoad>(_load);
on<CarSelectConfirm>(_confirm);
on<CarSelectChange>(_change);
on<CarSelectCancel>(_cancel);
}
Future<void> _load(
CarSelectLoad event,
Emitter<CarSelectState> emit,
) async {
try {
emit(CarSelectLoading());
final CarSelection? stored = await repository.getSelection();
final today = DateTime.now();
final bool validForToday =
stored != null &&
stored.selectedCarId != null &&
stored.selectedCarPlate != null &&
stored.date.year == today.year &&
stored.date.month == today.month &&
stored.date.day == today.day;
if (validForToday) {
emit(
CarSelectComplete(
selectedCar: Car(
id: stored.selectedCarId!,
plate: stored.selectedCarPlate!,
),
),
);
} else {
emit(CarSelectRequired());
}
} catch (e, st) {
debugPrint('CarSelectBloc._load failed: $e');
debugPrint('Stacktrace: $st');
emit(CarSelectFailed());
}
}
void _change(CarSelectChange event, Emitter<CarSelectState> emit) {
final previousCar =
state is CarSelectComplete ? (state as CarSelectComplete).selectedCar : null;
emit(CarSelectRequired(previousCar: previousCar));
}
void _cancel(CarSelectCancel event, Emitter<CarSelectState> emit) {
// Restore without touching SharedPreferences — no tour reload needed.
emit(CarSelectComplete(selectedCar: event.car));
}
Future<void> _confirm(
CarSelectConfirm event,
Emitter<CarSelectState> emit,
) async {
try {
final today = DateTime.now();
await repository.saveSelection(
CarSelection(
date: today,
selectedCarId: event.car.id,
selectedCarPlate: event.car.plate,
),
);
emit(CarSelectComplete(selectedCar: event.car));
} catch (e, st) {
debugPrint('CarSelectBloc._confirm failed: $e');
debugPrint('Stacktrace: $st');
emit(CarSelectFailed());
}
}
}

View File

@ -0,0 +1,25 @@
import 'package:hl_lieferservice/model/car.dart';
abstract class CarSelectEvent {}
/// Fired at app startup to check if a car has already been selected for today.
class CarSelectLoad extends CarSelectEvent {}
/// Fired when the driver confirms their car choice for the day.
class CarSelectConfirm extends CarSelectEvent {
final Car car;
CarSelectConfirm({required this.car});
}
/// Fired when the driver wants to switch to a different car.
/// Resets the selection so the enforcer shows the picker again.
class CarSelectChange extends CarSelectEvent {}
/// Fired when the driver cancels the change and wants to keep the previous car.
/// Restores [CarSelectComplete] without writing to SharedPreferences.
class CarSelectCancel extends CarSelectEvent {
final Car car;
CarSelectCancel({required this.car});
}

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,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/events.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/car_selection/presentation/car_selection_page.dart';
class CarSelectionEnforcer extends StatefulWidget {
final Widget child;
const CarSelectionEnforcer({super.key, required this.child});
@override
State<CarSelectionEnforcer> createState() => _CarSelectionEnforcerState();
}
class _CarSelectionEnforcerState extends State<CarSelectionEnforcer> {
@override
void initState() {
super.initState();
context.read<CarSelectBloc>().add(CarSelectLoad());
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CarSelectBloc, CarSelectState>(
builder: (context, state) {
// Show a full-screen spinner only while the persisted selection is
// being read from SharedPreferences (at most one frame on cold start).
if (state is CarSelectInitial || state is CarSelectLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (state is CarSelectFailed) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 72,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 24),
const Text(
"Fehler beim Laden der Fahrzeugauswahl.",
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () =>
context.read<CarSelectBloc>().add(CarSelectLoad()),
child: const Text("Erneut versuchen"),
),
],
),
),
),
);
}
// For both CarSelectRequired and CarSelectComplete, keep Home alive
// in the widget tree so initState is never re-triggered. The selection
// page is overlaid on top when a (re-)selection is required.
return Stack(
children: [
widget.child,
if (state is CarSelectRequired)
Positioned.fill(
child: CarSelectionPage(previousCar: state.previousCar),
),
],
);
},
);
}
}

View File

@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/events.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/car_selection/presentation/car_selection_card.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_event.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_dialog.dart';
import 'package:hl_lieferservice/model/car.dart';
class CarSelectionPage extends StatefulWidget {
/// When set, the page is in "change" mode: the car is pre-highlighted
/// and a cancel button is shown to revert without choosing a new car.
final Car? previousCar;
const CarSelectionPage({super.key, this.previousCar});
@override
State<CarSelectionPage> createState() => _CarSelectionPageState();
}
class _CarSelectionPageState extends State<CarSelectionPage> {
Car? _selectedCar;
bool get _isChanging => widget.previousCar != null;
@override
void initState() {
super.initState();
_selectedCar = widget.previousCar;
final authState = context.read<AuthBloc>().state as Authenticated;
context.read<CarsBloc>().add(CarLoad(teamId: authState.user.number));
}
void _onAddCar() {
final authState = context.read<AuthBloc>().state as Authenticated;
showDialog(
context: context,
builder: (_) => CarDialog(
onAction: (plate) {
context.read<CarsBloc>().add(
CarAdd(teamId: authState.user.number, plate: plate),
);
},
),
);
}
void _onConfirm() {
if (_selectedCar == null) return;
context.read<CarSelectBloc>().add(CarSelectConfirm(car: _selectedCar!));
}
Widget _buildCarList(List<Car> cars) {
if (cars.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.local_shipping_outlined, size: 72, color: Colors.grey),
const SizedBox(height: 24),
Text(
"Noch kein Fahrzeug vorhanden.",
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
"Füge zuerst ein Fahrzeug hinzu, bevor du fortfahren kannst.",
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _onAddCar,
icon: const Icon(Icons.add),
label: const Text("Fahrzeug hinzufügen"),
),
],
),
),
);
}
final authState = context.read<AuthBloc>().state as Authenticated;
return RefreshIndicator(
onRefresh: () async {
context.read<CarsBloc>().add(
CarLoad(teamId: authState.user.number, force: true),
);
},
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: cars.length,
itemBuilder: (context, index) {
final car = cars[index];
return CarSelectionCard(
car: car,
isSelected: _selectedCar?.id == car.id,
onTap: () => setState(() => _selectedCar = car),
);
},
),
);
}
@override
Widget build(BuildContext context) {
return BlocListener<CarSelectBloc, CarSelectState>(
listener: (context, state) {
if (state is CarSelectFailed) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Fehler beim Speichern der Fahrzeugauswahl."),
),
);
}
},
child: Scaffold(
appBar: _isChanging
? AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => context.read<CarSelectBloc>().add(
CarSelectCancel(car: widget.previousCar!),
),
),
title: const Text("Fahrzeug wechseln"),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
)
: null,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!_isChanging) ...[
Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 4),
child: Text(
"Fahrzeug auswählen",
style: Theme.of(context).textTheme.headlineSmall,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 16),
child: Text(
"Wähle das Fahrzeug aus, das du heute verwendest.",
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
),
),
],
Expanded(
child: BlocBuilder<CarsBloc, CarsState>(
builder: (context, state) {
if (state is CarsLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is CarsLoaded) {
return _buildCarList(state.cars);
}
if (state is CarsLoadingFailed) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 72,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 24),
const Text(
"Fehler beim Laden der Fahrzeuge.",
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
final authState =
context.read<AuthBloc>().state
as Authenticated;
context.read<CarsBloc>().add(
CarLoad(
teamId: authState.user.number,
),
);
},
child: const Text("Erneut versuchen"),
),
],
),
),
);
}
return const SizedBox.shrink();
},
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _selectedCar != null ? _onConfirm : null,
child: const Text("Auswählen"),
),
),
),
],
),
),
),
);
}
}

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,34 @@
import 'package:hl_lieferservice/feature/cars/model/selection.dart';
import 'package:shared_preferences/shared_preferences.dart';
class CarSelectionRepository {
static const _keyDate = 'car_selection_date';
static const _keyCarId = 'car_selection_car_id';
static const _keyCarPlate = 'car_selection_car_plate';
/// Returns the stored [CarSelection], or null if nothing has been saved yet.
Future<CarSelection?> getSelection() async {
final prefs = await SharedPreferences.getInstance();
final dateString = prefs.getString(_keyDate);
final carId = prefs.getInt(_keyCarId);
final plate = prefs.getString(_keyCarPlate);
if (dateString == null || carId == null || plate == null) return null;
return CarSelection(
date: DateTime.parse(dateString),
selectedCarId: carId,
selectedCarPlate: plate,
);
}
/// Persists the given [selection] locally on this device.
Future<void> saveSelection(CarSelection selection) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyDate, selection.date.toIso8601String());
await prefs.setInt(_keyCarId, selection.selectedCarId!);
await prefs.setString(_keyCarPlate, selection.selectedCarPlate!);
}
}

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());
}
}
@ -33,7 +52,6 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
final currentState = state;
try {
opBloc.add(LoadOperation());
Car newCar = await repository.add(event.teamId, event.plate);
if (currentState is CarsLoaded) {
@ -46,7 +64,7 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
opBloc.add(FinishOperation(message: "Auto erfolgreich hinzugefügt"));
} catch (e) {
opBloc.add(FailOperation(message: "Fehler beim Hinzufügen eines Autos"));
_handleError(e, "Fehler beim Hinzufügen eines Autos");
}
}
@ -54,7 +72,6 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
final currentState = state;
try {
opBloc.add(LoadOperation());
await repository.edit(event.teamId, event.newCar);
if (currentState is CarsLoaded) {
@ -74,7 +91,7 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
opBloc.add(FinishOperation(message: "Auto erfolgreich editiert"));
} catch (e) {
opBloc.add(FailOperation(message: "Fehler beim Editieren des Autos"));
_handleError(e, "Fehler beim Editieren des Autos");
}
}
@ -82,7 +99,6 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
final currentState = state;
try {
opBloc.add(LoadOperation());
await repository.delete(event.carId, event.teamId);
if (currentState is CarsLoaded) {
@ -100,7 +116,7 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
opBloc.add(FinishOperation(message: "Auto erfolgreich gelöscht"));
} catch (e) {
opBloc.add(FailOperation(message: "Fehler beim Löschen des Autos"));
_handleError(e, "Fehler beim Löschen des Autos");
}
}
}

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,12 +40,29 @@ class CarCard extends StatelessWidget {
child: Icon(
Icons.local_shipping,
size: 32,
color: Theme.of(context).primaryColor,
color: primary,
),
),
Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(car.plate),
child: Text(
car.plate,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
),
if (isSelected)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(
Icons.check_circle,
size: 20,
color: primary,
semanticLabel: 'Aktuell ausgewählt',
),
),
],
),

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 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,
@ -61,17 +65,23 @@ class TourBloc extends Bloc<TourEvent, TourState> {
@override
Future<void> close() {
_combinedSubscription?.cancel();
return super.close();
}
void _handleError(Object e, String fallbackMessage) {
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
} else {
opBloc.add(FailOperation(message: fallbackMessage));
}
}
void _setArticleAmount(
SetArticleAmountEvent event,
Emitter<TourState> emit,
) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
try {
await tourRepository.setArticleAmount(
event.deliveryId,
@ -79,15 +89,9 @@ class TourBloc extends Bloc<TourEvent, TourState> {
event.amount,
event.reason,
);
opBloc.add(FinishOperation());
} catch (e, st) {
opBloc.add(
FailOperation(message: "Fehler beim Ändern der Menge des Artikels"),
);
debugPrint("$e");
debugPrint("$st");
debugPrint("$e $st");
_handleError(e, "Fehler beim Ändern der Menge des Artikels");
}
}
}
@ -126,7 +130,6 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Emitter<TourState> emit,
) async {
Map<String, double> distances = {};
opBloc.add(LoadOperation());
emit(TourRequestingDistances(tour: event.tour, payments: event.payments));
@ -145,7 +148,6 @@ class TourBloc extends Bloc<TourEvent, TourState> {
}
}
opBloc.add(FinishOperation());
// If an error occurred, then the distances will be empty
// If the distances are empty then they shouldn't be displayed
add(
@ -251,17 +253,11 @@ class TourBloc extends Bloc<TourEvent, TourState> {
) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
try {
await tourRepository.reactivateDelivery(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Zurückstellen der Lieferung"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Zurückstellen der Lieferung");
}
}
}
@ -269,17 +265,11 @@ class TourBloc extends Bloc<TourEvent, TourState> {
void _holdDelivery(HoldDeliveryEvent event, Emitter<TourState> emit) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
try {
await tourRepository.holdDelivery(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Zurückstellen der Lieferung"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Zurückstellen der Lieferung");
}
}
}
@ -290,24 +280,17 @@ class TourBloc extends Bloc<TourEvent, TourState> {
) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
try {
await tourRepository.cancelDelivery(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Zurückstellen der Lieferung"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Stornieren der Lieferung");
}
}
}
void _scan(ScanArticleEvent event, Emitter<TourState> emit) async {
final currentState = state;
opBloc.add(LoadOperation());
if (currentState is TourLoaded) {
try {
@ -333,9 +316,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
break;
}
} catch (e, st) {
debugPrint("FEHLER beim Scannen eines Artikels: $e");
debugPrint("$st");
opBloc.add(FailOperation(message: "Fehler beim Scannen des Artikels"));
debugPrint("FEHLER beim Scannen eines Artikels: $e $st");
_handleError(e, "Fehler beim Scannen des Artikels");
}
}
}
@ -347,17 +329,15 @@ class TourBloc extends Bloc<TourEvent, TourState> {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
try {
await tourRepository.scanArticle(
event.deliveryId,
event.carId,
event.internalArticleId,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(st.toString());
opBloc.add(FailOperation(message: "Fehler beim Scannen des Artikels"));
debugPrint("$e $st");
_handleError(e, "Fehler beim Scannen des Artikels");
}
}
}
@ -365,34 +345,27 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Future<void> _assignCar(AssignCarEvent event, Emitter<TourState> emit) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
try {
await tourRepository.assignCar(event.deliveryId, event.carId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(st.toString());
opBloc.add(
FailOperation(message: "Fehler beim Zuweisen des Fahrzeugs"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Zuweisen des Fahrzeugs");
}
}
}
Future<void> _load(LoadTour event, Emitter<TourState> emit) async {
opBloc.add(LoadOperation());
try {
emit(TourLoading());
await tourRepository.loadTourOfToday(event.teamId);
await tourRepository.loadPaymentOptions();
opBloc.add(FinishOperation());
} catch (e) {
// go to the error state in order to give the user the chance
// to reload if necessary.
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
return;
}
emit(TourLoadingFailed());
opBloc.add(
FailOperation(message: "Fehler beim Laden der heutigen Fahrten"),
);
opBloc.add(FailOperation(message: "Fehler beim Laden der heutigen Fahrten"));
}
}
@ -401,7 +374,6 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Emitter<TourState> emit,
) async {
final currentState = state;
opBloc.add(LoadOperation());
if (currentState is TourLoaded) {
try {
@ -415,11 +387,9 @@ class TourBloc extends Bloc<TourEvent, TourState> {
);
await tourRepository.finishDelivery(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
opBloc.add(FailOperation(message: "Failed to update delivery"));
debugPrint(st.toString());
debugPrint("$e $st");
_handleError(e, "Fehler beim Abschließen der Lieferung");
}
}
}
@ -429,14 +399,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Emitter<TourState> emit,
) async {
try {
opBloc.add(LoadOperation());
await tourRepository.updatePayment(event.deliveryId, event.payment);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(st.toString());
opBloc.add(
FailOperation(message: "Fehler beim Aktualisieren des Betrags"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Aktualisieren des Betrags");
}
}
@ -445,18 +411,14 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Emitter<TourState> emit,
) async {
try {
opBloc.add(LoadOperation());
await tourRepository.updateOption(
event.deliveryId,
event.key,
event.value,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Aktualisieren der Optionen"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Aktualisieren der Optionen");
}
}
@ -464,26 +426,15 @@ class TourBloc extends Bloc<TourEvent, TourState> {
UpdateDiscountEvent event,
Emitter<TourState> emit,
) async {
opBloc.add(LoadOperation());
try {
opBloc.add(FinishOperation());
await tourRepository.updateDiscount(
event.deliveryId,
event.reason,
event.value,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(
"Fehler beim Hinzufügen eins Discounts zur Lieferung: ${event.deliveryId}:",
);
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Hinzufügen des Discounts: $e"),
);
debugPrint("Fehler beim Aktualisieren des Discounts: $e $st");
_handleError(e, "Fehler beim Aktualisieren des Discounts");
}
}
@ -491,51 +442,28 @@ class TourBloc extends Bloc<TourEvent, TourState> {
RemoveDiscountEvent event,
Emitter<TourState> emit,
) async {
opBloc.add(LoadOperation());
try {
await tourRepository.removeDiscount(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(
"Fehler beim Löschen des Discounts der Lieferung: ${event.deliveryId}:",
);
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Löschen des Discounts: $e"),
);
debugPrint("Fehler beim Löschen des Discounts: $e $st");
_handleError(e, "Fehler beim Löschen des Discounts");
}
}
void _addDiscount(AddDiscountEvent event, Emitter<TourState> emit) async {
opBloc.add(LoadOperation());
try {
await tourRepository.addDiscount(
event.deliveryId,
event.reason,
event.value,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(
"Fehler beim Hinzufügen eins Discounts zur Lieferung: ${event.deliveryId}:",
);
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Hinzufügen des Discounts: $e"),
);
debugPrint("Fehler beim Hinzufügen des Discounts: $e $st");
_handleError(e, "Fehler beim Hinzufügen des Discounts");
}
}
void _unscan(UnscanArticleEvent event, Emitter<TourState> emit) async {
opBloc.add(LoadOperation());
try {
await tourRepository.unscan(
event.deliveryId,
@ -543,29 +471,18 @@ class TourBloc extends Bloc<TourEvent, TourState> {
event.newAmount,
event.reason,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Unscan des Artikels: ${event.articleId}:");
debugPrint("$e");
debugPrint("$st");
opBloc.add(FailOperation(message: "Fehler beim Unscan des Artikels: $e"));
debugPrint("Fehler beim Unscan des Artikels ${event.articleId}: $e $st");
_handleError(e, "Fehler beim Unscan des Artikels");
}
}
void _resetAmount(ResetScanAmountEvent event, Emitter<TourState> emit) async {
opBloc.add(LoadOperation());
try {
await tourRepository.resetScan(event.articleId, event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Unscan des Artikels: ${event.articleId}:");
debugPrint("$e");
debugPrint("$st");
opBloc.add(FailOperation(message: "Fehler beim Zurücksetzen: $e"));
debugPrint("Fehler beim Zurücksetzen Artikel ${event.articleId}: $e $st");
_handleError(e, "Fehler beim Zurücksetzen");
}
}
}

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,32 +94,21 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
RemoveImageNote event,
Emitter<NoteState> emit,
) async {
opBloc.add(LoadOperation());
try {
await repository.deleteImage(event.deliveryId, event.objectId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Löschen des Bildes: $e");
debugPrint(st.toString());
opBloc.add(FailOperation(message: e.toString()));
debugPrint("Fehler beim Löschen des Bildes: $e $st");
_handleError(e, "Fehler beim Löschen des Bildes");
}
}
Future<void> _upload(AddImageNote event, Emitter<NoteState> emit) async {
opBloc.add(LoadOperation());
try {
Uint8List imageBytes = await event.file.readAsBytes();
await repository.addImage(event.deliveryId, imageBytes);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen des Bildes: $e");
debugPrint(st.toString());
opBloc.add(FailOperation(message: e.toString()));
debugPrint("Fehler beim Hinzufügen des Bildes: $e $st");
_handleError(e, "Fehler beim Hinzufügen des Bildes");
}
}
@ -117,61 +118,41 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
try {
await repository.loadNotes(event.delivery.id);
await repository.loadTemplates();
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Herunterladen der Notizen: $e");
debugPrint(st.toString());
opBloc.add(
FailOperation(message: "Notizen konnten nicht heruntergeladen werden."),
);
debugPrint("Fehler beim Herunterladen der Notizen: $e $st");
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
return;
}
opBloc.add(FailOperation(message: "Notizen konnten nicht heruntergeladen werden."));
emit.call(NoteLoadingFailed());
}
}
Future<void> _add(AddNote event, Emitter<NoteState> emit) async {
opBloc.add(LoadOperation());
try {
await repository.addNote(event.deliveryId, event.note);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen der Notiz: $e");
debugPrint(st.toString());
opBloc.add(FailOperation(message: e.toString()));
debugPrint("Fehler beim Hinzufügen der Notiz: $e $st");
_handleError(e, "Fehler beim Hinzufügen der Notiz");
}
}
Future<void> _edit(EditNote event, Emitter<NoteState> emit) async {
opBloc.add(LoadOperation());
try {
await repository.editNote(event.noteId, event.content);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen der Notiz: $e");
debugPrint(st.toString());
opBloc.add(FailOperation(message: e.toString()));
debugPrint("Fehler beim Editieren der Notiz: $e $st");
_handleError(e, "Fehler beim Editieren der Notiz");
}
}
Future<void> _remove(RemoveNote event, Emitter<NoteState> emit) async {
opBloc.add(LoadOperation());
try {
await repository.deleteNote(event.noteId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen der Notiz: $e");
debugPrint(st.toString());
opBloc.add(
FailOperation(message: "Notizen konnte nicht gelöscht werden."),
);
debugPrint("Fehler beim Löschen der Notiz: $e $st");
_handleError(e, "Notiz konnte nicht gelöscht werden");
}
}
}

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

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

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

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

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

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/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart';
@ -16,16 +18,45 @@ class DeliveryOverviewPage extends StatefulWidget {
class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
final carState = context.watch<CarSelectBloc>().state;
return Scaffold(
appBar: AppBar(
title: const Text("Auslieferung"),
centerTitle: false,
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
actions: [
if (carState is CarSelectComplete)
Padding(
padding: const EdgeInsets.only(right: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.local_shipping,
color: Theme.of(context).colorScheme.onSecondary,
size: 20,
),
const SizedBox(width: 6),
Text(
carState.selectedCar.plate,
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
body: BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is TourLoaded) {
final currentState = state;
return Center(
child: DeliveryOverview(
tour: currentState.tour,
distances: currentState.distances ?? {},
),
return DeliveryOverview(
tour: state.tour,
distances: state.distances ?? {},
);
}
@ -33,8 +64,9 @@ class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
return DeliveryLoadingFailedPage();
}
return Container();
return const Center(child: CircularProgressIndicator());
},
),
);
}
}

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 {

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

@ -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

@ -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,11 +52,23 @@ class _DeliveryAppState extends State<DeliveryApp> {
create:
(context) => TourBloc(
opBloc: context.read<OperationBloc>(),
authBloc: context.read<AuthBloc>(),
tourRepository: TourRepository(
service: TourService(),
),
),
),
BlocProvider(
create: (context) =>
CarSelectBloc(repository: CarSelectionRepository()),
),
BlocProvider(
create: (context) => CarsBloc(
repository: CarsRepository(service: CarService()),
opBloc: context.read<OperationBloc>(),
authBloc: context.read<AuthBloc>(),
),
),
],
child: MaterialApp(
home: OperationViewEnforcer(
@ -67,7 +85,9 @@ class _DeliveryAppState extends State<DeliveryApp> {
}
if (state is AppConfigLoaded) {
return LoginEnforcer(child: Home());
return LoginEnforcer(
child: CarSelectionEnforcer(child: Home()),
);
}
return Container();

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

@ -4,28 +4,19 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_state.dart';
class OperationBloc extends Bloc<OperationEvent, OperationState> {
OperationBloc() : super(OperationIdle()) {
on<LoadOperation>(_loadOperation);
on<FailOperation>(_failOperation);
on<FinishOperation>(_finishOperation);
}
Future<void> _loadOperation(LoadOperation event, Emitter<OperationState> emit) async {
emit(OperationLoading());
}
Future<void> _failOperation(FailOperation event, Emitter<OperationState> emit) async {
emit(OperationFailed(message: event.message));
await Future.delayed(Duration(seconds: 5));
await Future.delayed(const Duration(seconds: 5));
emit(OperationIdle());
}
Future<void> _finishOperation(FinishOperation event, Emitter<OperationState> emit) async {
emit(OperationFinished(message: event.message));
await Future.delayed(Duration(seconds: 5));
await Future.delayed(const Duration(seconds: 5));
emit(OperationIdle());
}
}

View File

@ -1,7 +1,5 @@
abstract class OperationEvent {}
class LoadOperation extends OperationEvent {}
class FailOperation extends OperationEvent {
String message;

View File

@ -2,8 +2,6 @@ abstract class OperationState {}
class OperationIdle extends OperationState {}
class OperationLoading extends OperationState {}
class OperationFailed extends OperationState {
String message;

View File

@ -4,48 +4,22 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import '../bloc/operation_state.dart';
/// OperationViewEnforcer
///
/// A view that encapsulates the functionality to react to asynchronous operations.
/// It is capable of showing a loading indicator while an operation is ongoing and it shows
/// a error message if the operation failed.
class OperationViewEnforcer extends StatefulWidget {
/// Listens to [OperationBloc] and shows SnackBars for success and error
/// messages. Loading indicators are handled locally by each feature.
class OperationViewEnforcer extends StatelessWidget {
final Widget child;
const OperationViewEnforcer({super.key, required this.child});
@override
State<OperationViewEnforcer> createState() => _OperationViewEnforcerState();
}
class _OperationViewEnforcerState extends State<OperationViewEnforcer> {
OverlayEntry? _overlayEntry;
@override
void dispose() {
_overlayEntry?.remove();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<OperationBloc, OperationState>(
listener: (context, state) {
if (state is OperationLoading) {
if (_overlayEntry == null) {
_overlayEntry = _createOverlayEntry(context);
Overlay.of(context).insert(_overlayEntry!);
}
} else {
_overlayEntry?.remove();
_overlayEntry = null;
}
if (state is OperationFinished) {
if (state.message != null) {
if (state is OperationFinished && state.message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message!)),
);
}
}
if (state is OperationFailed) {
ScaffoldMessenger.of(context).showSnackBar(
@ -53,20 +27,7 @@ class _OperationViewEnforcerState extends State<OperationViewEnforcer> {
);
}
},
child: widget.child,
);
}
OverlayEntry _createOverlayEntry(BuildContext context) {
return OverlayEntry(
builder: (context) => DecoratedBox(
decoration: const BoxDecoration(
color: Color.fromRGBO(128, 128, 128, 0.8),
),
child: const Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
child: child,
);
}
}

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: