Added components to article

This commit is contained in:
Dennis Nemec
2026-05-11 17:12:05 +02:00
parent 2470299a10
commit ac6b03227d
37 changed files with 1189 additions and 513 deletions

View File

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

View File

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

23
lib/dto/component.dart Normal file
View File

@ -0,0 +1,23 @@
import 'package:json_annotation/json_annotation.dart';
part 'component.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class ComponentDTO {
ComponentDTO({
required this.articleNr,
required this.name,
required this.quantity,
required this.pos,
});
String articleNr;
String name;
String quantity;
String pos;
factory ComponentDTO.fromJson(Map<String, dynamic> json) =>
_$ComponentDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$ComponentDTOToJson(this);
}

22
lib/dto/component.g.dart Normal file
View File

@ -0,0 +1,22 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'component.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ComponentDTO _$ComponentDTOFromJson(Map<String, dynamic> json) => ComponentDTO(
articleNr: json['article_nr'] as String,
name: json['name'] as String,
quantity: json['quantity'] as String,
pos: json['pos'] as String,
);
Map<String, dynamic> _$ComponentDTOToJson(ComponentDTO instance) =>
<String, dynamic>{
'article_nr': instance.articleNr,
'name': instance.name,
'quantity': instance.quantity,
'pos': instance.pos,
};

View File

@ -25,6 +25,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
try {
debugPrint("Retrieve user information");
emit(Authenticating());
var response = await service.getUserinfo(event.sessionId);
var state = Authenticated(sessionId: event.sessionId, user: response);
locator.registerSingleton<Authenticated>(state);
@ -34,6 +35,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
debugPrint(err.toString());
debugPrint(st.toString());
emit(Unauthenticated());
operationBloc.add(
FailOperation(
message: "Login war nicht erfolgreich. Probieren Sie es erneut.",

View File

@ -7,6 +7,10 @@ class Unauthenticated extends AuthState {
Unauthenticated({this.sessionExpired = false});
}
/// Transient state while [SetAuthenticatedEvent] is being processed and the
/// user info is being fetched from the server.
class Authenticating extends AuthState {}
class Authenticated extends AuthState {
User user;
String sessionId;

View File

@ -3,7 +3,9 @@ import 'package:app_links/app_links.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:hl_lieferservice/util.dart';
import 'dart:async';
class LoginPage extends StatefulWidget {
@ -60,9 +62,7 @@ class _LoginPageState extends State<LoginPage> {
// Small delay to ensure listener is ready
await Future.delayed(const Duration(milliseconds: 500));
debugPrint("🔵 Opening browser to: http://localhost:3000/login");
final loginUrl = Uri.parse('http://192.168.1.9:3000/login');
final loginUrl = Uri.parse('${getConfig().backendUrl}/login');
final launched = await launchUrl(
loginUrl,
mode: LaunchMode.externalApplication,
@ -176,17 +176,30 @@ class _LoginPageState extends State<LoginPage> {
children: [
Padding(
padding: const EdgeInsets.only(top: 15, bottom: 15),
child: _isLoading
? const Column(
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Warte auf Login...'),
],
)
: OutlinedButton(
onPressed: _onPressLogin,
child: const Text("Anmelden mit Holzleitner Login"),
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, authState) {
final isBusy =
_isLoading || authState is Authenticating;
if (!isBusy) {
return OutlinedButton(
onPressed: _onPressLogin,
child: const Text(
"Anmelden mit Holzleitner Login",
),
);
}
return Column(
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
authState is Authenticating
? 'Anmeldung wird abgeschlossen…'
: 'Warte auf Login...',
),
],
);
},
),
),
],

View File

@ -24,7 +24,7 @@ class CarSelectBloc extends Bloc<CarSelectEvent, CarSelectState> {
try {
emit(CarSelectLoading());
final CarSelection? stored = await repository.getSelection();
final CarSelection? stored = await repository.getSelection(event.userId);
final today = DateTime.now();
final bool validForToday =
@ -72,6 +72,7 @@ class CarSelectBloc extends Bloc<CarSelectEvent, CarSelectState> {
try {
final today = DateTime.now();
await repository.saveSelection(
event.userId,
CarSelection(
date: today,
selectedCarId: event.car.id,

View File

@ -2,14 +2,20 @@ 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 at app startup to check if a car has already been selected for today
/// for the given user.
class CarSelectLoad extends CarSelectEvent {
final String userId;
CarSelectLoad({required this.userId});
}
/// Fired when the driver confirms their car choice for the day.
class CarSelectConfirm extends CarSelectEvent {
final String userId;
final Car car;
CarSelectConfirm({required this.car});
CarSelectConfirm({required this.userId, required this.car});
}
/// Fired when the driver wants to switch to a different car.
@ -22,4 +28,4 @@ class CarSelectCancel extends CarSelectEvent {
final Car car;
CarSelectCancel({required this.car});
}
}

View File

@ -1,5 +1,7 @@
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';
@ -18,7 +20,12 @@ class _CarSelectionEnforcerState extends State<CarSelectionEnforcer> {
@override
void initState() {
super.initState();
context.read<CarSelectBloc>().add(CarSelectLoad());
final authState = context.read<AuthBloc>().state;
if (authState is Authenticated) {
context
.read<CarSelectBloc>()
.add(CarSelectLoad(userId: authState.user.number));
}
}
@override
@ -53,8 +60,14 @@ class _CarSelectionEnforcerState extends State<CarSelectionEnforcer> {
),
const SizedBox(height: 16),
FilledButton(
onPressed: () =>
context.read<CarSelectBloc>().add(CarSelectLoad()),
onPressed: () {
final authState = context.read<AuthBloc>().state;
if (authState is Authenticated) {
context.read<CarSelectBloc>().add(
CarSelectLoad(userId: authState.user.number),
);
}
},
child: const Text("Erneut versuchen"),
),
],

View File

@ -52,7 +52,13 @@ class _CarSelectionPageState extends State<CarSelectionPage> {
void _onConfirm() {
if (_selectedCar == null) return;
context.read<CarSelectBloc>().add(CarSelectConfirm(car: _selectedCar!));
final authState = context.read<AuthBloc>().state as Authenticated;
context.read<CarSelectBloc>().add(
CarSelectConfirm(
userId: authState.user.number,
car: _selectedCar!,
),
);
}
Widget _buildCarList(List<Car> cars) {

View File

@ -2,17 +2,19 @@ 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';
static String _keyDate(String userId) => 'car_selection_${userId}_date';
static String _keyCarId(String userId) => 'car_selection_${userId}_car_id';
static String _keyCarPlate(String userId) =>
'car_selection_${userId}_car_plate';
/// Returns the stored [CarSelection], or null if nothing has been saved yet.
Future<CarSelection?> getSelection() async {
/// Returns the stored [CarSelection] for the given user, or null if nothing
/// has been saved yet for that user.
Future<CarSelection?> getSelection(String userId) async {
final prefs = await SharedPreferences.getInstance();
final dateString = prefs.getString(_keyDate);
final carId = prefs.getInt(_keyCarId);
final plate = prefs.getString(_keyCarPlate);
final dateString = prefs.getString(_keyDate(userId));
final carId = prefs.getInt(_keyCarId(userId));
final plate = prefs.getString(_keyCarPlate(userId));
if (dateString == null || carId == null || plate == null) return null;
@ -23,12 +25,12 @@ class CarSelectionRepository {
);
}
/// Persists the given [selection] locally on this device.
Future<void> saveSelection(CarSelection selection) async {
/// Persists the given [selection] for the given user locally on this device.
Future<void> saveSelection(String userId, CarSelection selection) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyDate, selection.date.toIso8601String());
await prefs.setInt(_keyCarId, selection.selectedCarId!);
await prefs.setString(_keyCarPlate, selection.selectedCarPlate!);
await prefs.setString(_keyDate(userId), selection.date.toIso8601String());
await prefs.setInt(_keyCarId(userId), selection.selectedCarId!);
await prefs.setString(_keyCarPlate(userId), selection.selectedCarPlate!);
}
}
}

View File

@ -51,6 +51,7 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
Future<void> _carAdd(CarAdd event, Emitter<CarsState> emit) async {
final currentState = state;
opBloc.add(StartOperation());
try {
Car newCar = await repository.add(event.teamId, event.plate);
@ -71,6 +72,7 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
Future<void> _carEdit(CarEdit event, Emitter<CarsState> emit) async {
final currentState = state;
opBloc.add(StartOperation());
try {
await repository.edit(event.teamId, event.newCar);
@ -98,6 +100,7 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
Future<void> _carDelete(CarDelete event, Emitter<CarsState> emit) async {
final currentState = state;
opBloc.add(StartOperation());
try {
await repository.delete(event.carId, event.teamId);

View File

@ -43,6 +43,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
on<AssignCarEvent>(_assignCar);
on<IncrementArticleScanAmount>(_increment);
on<ScanArticleEvent>(_scan);
on<ScanComponentEvent>(_scanComponent);
on<HoldDeliveryEvent>(_holdDelivery);
on<CancelDeliveryEvent>(_cancelDelivery);
on<ReactivateDeliveryEvent>(_reactivateDelivery);
@ -82,6 +83,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(StartOperation());
try {
await tourRepository.setArticleAmount(
event.deliveryId,
@ -89,6 +91,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
event.amount,
event.reason,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e $st");
_handleError(e, "Fehler beim Ändern der Menge des Artikels");
@ -131,8 +134,6 @@ class TourBloc extends Bloc<TourEvent, TourState> {
) async {
Map<String, double> distances = {};
emit(TourRequestingDistances(tour: event.tour, payments: event.payments));
for (final delivery in event.tour.deliveries) {
try {
distances[delivery.id] = await DistanceService.getDistanceByRoad(
@ -141,22 +142,14 @@ class TourBloc extends Bloc<TourEvent, TourState> {
} catch (e, st) {
debugPrint("Fehler beim Laden der Distanz: $e");
debugPrint("$st");
// set the distance to none in order to handle the error case
// afterwards for that specific delivery
distances[delivery.id] = double.nan;
}
}
// If an error occurred, then the distances will be empty
// If the distances are empty then they shouldn't be displayed
add(
RequestSortingInformationEvent(
tour: event.tour,
payments: event.payments,
distances: distances,
),
);
final currentState = state;
if (currentState is TourLoaded) {
emit(currentState.copyWith(distances: distances));
}
}
void _requestSortingInformation(
@ -217,9 +210,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
tour: event.tour,
paymentOptions: event.payments,
sortingInformation: container,
distances: event.distances,
),
);
add(RequestDeliveryDistanceEvent(tour: event.tour));
}
void _updated(TourUpdated event, Emitter<TourState> emit) {
@ -235,14 +229,14 @@ class TourBloc extends Bloc<TourEvent, TourState> {
paymentOptions: payments,
distances: Map<String, double>.from(currentState.distances ?? {}),
sortingInformation: currentState.sortingInformation,
pendingScanRequests: currentState.pendingScanRequests,
),
);
}
// Download distances if tour has previously fetched by API
if (currentState is TourLoading) {
add(
RequestDeliveryDistanceEvent(tour: tour.copyWith(), payments: payments),
RequestSortingInformationEvent(tour: tour.copyWith(), payments: payments),
);
}
}
@ -253,8 +247,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(StartOperation());
try {
await tourRepository.reactivateDelivery(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e $st");
_handleError(e, "Fehler beim Zurückstellen der Lieferung");
@ -265,8 +261,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
void _holdDelivery(HoldDeliveryEvent event, Emitter<TourState> emit) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(StartOperation());
try {
await tourRepository.holdDelivery(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e $st");
_handleError(e, "Fehler beim Zurückstellen der Lieferung");
@ -280,8 +278,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(StartOperation());
try {
await tourRepository.cancelDelivery(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e $st");
_handleError(e, "Fehler beim Stornieren der Lieferung");
@ -289,10 +289,58 @@ class TourBloc extends Bloc<TourEvent, TourState> {
}
}
void _bumpPendingScans(Emitter<TourState> emit, int delta) {
final currentState = state;
if (currentState is TourLoaded) {
final next = (currentState.pendingScanRequests + delta).clamp(0, 1 << 30);
emit(currentState.copyWith(pendingScanRequests: next));
}
}
void _scanComponent(
ScanComponentEvent event,
Emitter<TourState> emit,
) async {
final currentState = state;
if (currentState is TourLoaded) {
_bumpPendingScans(emit, 1);
try {
switch (await tourRepository.scanComponent(
event.deliveryId,
event.carId,
event.componentArticleNumber,
)) {
case ScanResult.scanned:
opBloc.add(FinishOperation(message: 'Komponente gescannt'));
break;
case ScanResult.alreadyScanned:
opBloc.add(
FailOperation(message: 'Komponente wurde bereits gescannt'),
);
break;
case ScanResult.notFound:
opBloc.add(
FailOperation(
message: 'Komponente ist für keine Lieferung vorgesehen',
),
);
break;
}
} catch (e, st) {
debugPrint("FEHLER beim Scannen einer Komponente: $e $st");
_handleError(e, "Fehler beim Scannen der Komponente");
} finally {
_bumpPendingScans(emit, -1);
}
}
}
void _scan(ScanArticleEvent event, Emitter<TourState> emit) async {
final currentState = state;
if (currentState is TourLoaded) {
_bumpPendingScans(emit, 1);
try {
switch (await tourRepository.scanArticle(
event.deliveryId,
@ -318,6 +366,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
} catch (e, st) {
debugPrint("FEHLER beim Scannen eines Artikels: $e $st");
_handleError(e, "Fehler beim Scannen des Artikels");
} finally {
_bumpPendingScans(emit, -1);
}
}
}
@ -329,6 +379,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
final currentState = state;
if (currentState is TourLoaded) {
_bumpPendingScans(emit, 1);
try {
await tourRepository.scanArticle(
event.deliveryId,
@ -338,6 +389,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
} catch (e, st) {
debugPrint("$e $st");
_handleError(e, "Fehler beim Scannen des Artikels");
} finally {
_bumpPendingScans(emit, -1);
}
}
}
@ -345,8 +398,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Future<void> _assignCar(AssignCarEvent event, Emitter<TourState> emit) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(StartOperation());
try {
await tourRepository.assignCar(event.deliveryId, event.carId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e $st");
_handleError(e, "Fehler beim Zuweisen des Fahrzeugs");
@ -376,6 +431,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(StartOperation(message: "Lieferung wird abgeschlossen…"));
try {
await tourRepository.uploadDriverSignature(
event.deliveryId,
@ -387,6 +443,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
);
await tourRepository.finishDelivery(event.deliveryId);
opBloc.add(FinishOperation(message: "Lieferung abgeschlossen"));
} catch (e, st) {
debugPrint("$e $st");
_handleError(e, "Fehler beim Abschließen der Lieferung");
@ -398,8 +455,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
UpdateSelectedPaymentMethodEvent event,
Emitter<TourState> emit,
) async {
opBloc.add(StartOperation());
try {
await tourRepository.updatePayment(event.deliveryId, event.payment);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e $st");
_handleError(e, "Fehler beim Aktualisieren des Betrags");
@ -410,12 +469,14 @@ class TourBloc extends Bloc<TourEvent, TourState> {
UpdateDeliveryOptionEvent event,
Emitter<TourState> emit,
) async {
opBloc.add(StartOperation());
try {
await tourRepository.updateOption(
event.deliveryId,
event.key,
event.value,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e $st");
_handleError(e, "Fehler beim Aktualisieren der Optionen");
@ -426,12 +487,14 @@ class TourBloc extends Bloc<TourEvent, TourState> {
UpdateDiscountEvent event,
Emitter<TourState> emit,
) async {
opBloc.add(StartOperation());
try {
await tourRepository.updateDiscount(
event.deliveryId,
event.reason,
event.value,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Aktualisieren des Discounts: $e $st");
_handleError(e, "Fehler beim Aktualisieren des Discounts");
@ -442,8 +505,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
RemoveDiscountEvent event,
Emitter<TourState> emit,
) async {
opBloc.add(StartOperation());
try {
await tourRepository.removeDiscount(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Löschen des Discounts: $e $st");
_handleError(e, "Fehler beim Löschen des Discounts");
@ -451,12 +516,14 @@ class TourBloc extends Bloc<TourEvent, TourState> {
}
void _addDiscount(AddDiscountEvent event, Emitter<TourState> emit) async {
opBloc.add(StartOperation());
try {
await tourRepository.addDiscount(
event.deliveryId,
event.reason,
event.value,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen des Discounts: $e $st");
_handleError(e, "Fehler beim Hinzufügen des Discounts");
@ -464,6 +531,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
}
void _unscan(UnscanArticleEvent event, Emitter<TourState> emit) async {
opBloc.add(StartOperation());
try {
await tourRepository.unscan(
event.deliveryId,
@ -471,6 +539,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
event.newAmount,
event.reason,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Unscan des Artikels ${event.articleId}: $e $st");
_handleError(e, "Fehler beim Unscan des Artikels");
@ -478,8 +547,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
}
void _resetAmount(ResetScanAmountEvent event, Emitter<TourState> emit) async {
opBloc.add(StartOperation());
try {
await tourRepository.resetScan(event.articleId, event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Zurücksetzen Artikel ${event.articleId}: $e $st");
_handleError(e, "Fehler beim Zurücksetzen");

View File

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

View File

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

View File

@ -94,8 +94,10 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
RemoveImageNote event,
Emitter<NoteState> emit,
) async {
opBloc.add(StartOperation());
try {
await repository.deleteImage(event.deliveryId, event.objectId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Löschen des Bildes: $e $st");
_handleError(e, "Fehler beim Löschen des Bildes");
@ -103,9 +105,11 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
}
Future<void> _upload(AddImageNote event, Emitter<NoteState> emit) async {
opBloc.add(StartOperation());
try {
Uint8List imageBytes = await event.file.readAsBytes();
await repository.addImage(event.deliveryId, imageBytes);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen des Bildes: $e $st");
_handleError(e, "Fehler beim Hinzufügen des Bildes");
@ -113,6 +117,10 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
}
Future<void> _load(LoadNote event, Emitter<NoteState> emit) async {
if (state is NoteLoaded || state is NoteLoading) {
return;
}
emit.call(NoteLoading());
try {
@ -130,8 +138,10 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
}
Future<void> _add(AddNote event, Emitter<NoteState> emit) async {
opBloc.add(StartOperation());
try {
await repository.addNote(event.deliveryId, event.note);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen der Notiz: $e $st");
_handleError(e, "Fehler beim Hinzufügen der Notiz");
@ -139,8 +149,10 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
}
Future<void> _edit(EditNote event, Emitter<NoteState> emit) async {
opBloc.add(StartOperation());
try {
await repository.editNote(event.noteId, event.content);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Editieren der Notiz: $e $st");
_handleError(e, "Fehler beim Editieren der Notiz");
@ -148,8 +160,10 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
}
Future<void> _remove(RemoveNote event, Emitter<NoteState> emit) async {
opBloc.add(StartOperation());
try {
await repository.deleteNote(event.noteId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Löschen der Notiz: $e $st");
_handleError(e, "Notiz konnte nicht gelöscht werden");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,12 +11,12 @@ import '../../detail/service/notes_service.dart';
class DeliveryListItem extends StatelessWidget {
final Delivery delivery;
final double distance;
final double? distance;
const DeliveryListItem({
super.key,
required this.delivery,
required this.distance,
this.distance,
});
void _goToDelivery(BuildContext context) {
@ -59,11 +59,14 @@ class DeliveryListItem extends StatelessWidget {
"Pausiert",
);
case DeliveryState.ongoing:
final distanceLabel = distance != null && !distance!.isNaN
? "${distance!.toStringAsFixed(1)} km"
: "";
return (
Theme.of(context).colorScheme.surfaceContainerLow,
Colors.transparent,
Icons.local_shipping_outlined,
"${distance.toStringAsFixed(1)} km",
distanceLabel,
);
}
}

View File

@ -43,7 +43,7 @@ class _DeliveryListState extends State<DeliveryList> {
return DeliveryListItem(
delivery: delivery,
distance: distances[delivery.id] ?? 0.0,
distance: distances[delivery.id],
);
},
itemCount: sortingInformation.length,
@ -114,7 +114,7 @@ class _DeliveryListState extends State<DeliveryList> {
itemCount: sorted.length,
itemBuilder: (context, index) => DeliveryListItem(
delivery: sorted[index],
distance: currentState.distances?[sorted[index].id] ?? 0.0,
distance: currentState.distances?[sorted[index].id],
),
);
}

View File

@ -18,11 +18,9 @@ class DeliveryOverview extends StatefulWidget {
const DeliveryOverview({
super.key,
required this.tour,
required this.distances,
});
final Tour tour;
final Map<String, double> distances;
@override
State<StatefulWidget> createState() => _DeliveryOverviewState();

View File

@ -4,7 +4,7 @@ import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart';
import 'package:hl_lieferservice/model/tour.dart';
import '../../bloc/tour_bloc.dart';
import '../../bloc/tour_state.dart';
@ -16,6 +16,36 @@ class DeliveryOverviewPage extends StatefulWidget {
}
class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
Widget _buildOverviewWithBanner({
required Tour tour,
required String bannerText,
}) {
return Column(
children: [
Material(
color: Colors.amber.shade100,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 12),
Expanded(child: Text(bannerText)),
],
),
),
),
Expanded(
child: DeliveryOverview(tour: tour),
),
],
);
}
@override
Widget build(BuildContext context) {
final carState = context.watch<CarSelectBloc>().state;
@ -54,10 +84,13 @@ class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
body: BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is TourLoaded) {
return DeliveryOverview(
tour: state.tour,
distances: state.distances ?? {},
);
if (state.distances == null) {
return _buildOverviewWithBanner(
tour: state.tour,
bannerText: "Berechne Distanzen…",
);
}
return DeliveryOverview(tour: state.tour);
}
if (state is TourLoadingFailed) {

View File

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

View File

@ -13,6 +13,7 @@ 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/component.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
@ -24,37 +25,42 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
// Data helpers
// ---------------------------------------------------------------------------
class _ArticleDeliveryEntry {
class _DeliveryGroup {
final Delivery delivery;
final Article article;
final String? carPlate;
final List<Article> articles;
const _ArticleDeliveryEntry({
const _DeliveryGroup({
required this.delivery,
required this.article,
required this.articles,
this.carPlate,
});
}
class _ArticleGroup {
final String articleNumber;
final String name;
final int totalAmount;
final int totalScanned;
final int totalRemoved;
final List<_ArticleDeliveryEntry> entries;
int get totalArticles => articles.length;
bool get isComplete => totalScanned + totalRemoved >= totalAmount;
int get scannedOrRemoved => totalScanned + totalRemoved;
int get completeArticles => articles
.where((a) => a.isFullyScanned)
.length;
const _ArticleGroup({
required this.articleNumber,
required this.name,
required this.totalAmount,
required this.totalScanned,
required this.totalRemoved,
required this.entries,
});
int get totalUnits => articles.fold(0, (sum, a) {
if (a.isParent && a.components.isNotEmpty) {
return sum + a.components.fold(0, (s, c) => s + c.requiredAmount);
}
return sum + a.amount;
});
int get scannedUnits => articles.fold(0, (sum, a) {
if (a.isParent && a.components.isNotEmpty) {
return sum + a.components.fold(0, (s, c) => s + c.scannedAmount);
}
return sum + a.scannedAmount + a.scannedRemovedAmount;
});
bool get isComplete => totalArticles > 0 && completeArticles == totalArticles;
bool get hasAnyScanned => scannedUnits > 0;
bool get isPartial => hasAnyScanned && !isComplete;
}
// ---------------------------------------------------------------------------
@ -128,6 +134,8 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
/// `<artikelnummer>;<kundennummer>;<belegnummer>`.
/// Liefert `null`, wenn der Barcode dem erwarteten Format nicht entspricht.
String? _extractArticleNumber(String barcode) {
debugPrint("QR CODE: $barcode");
final parts = barcode.split(';');
if (parts.length != 3) return null;
final articleNumber = parts[0].trim();
@ -156,10 +164,43 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
final tourState = context.read<TourBloc>().state;
if (tourState is! TourLoaded) return;
// ── 1. Try component match first (Stückliste) ──
final componentDeliveries = tourState.tour.deliveries
.where((d) => d.state != DeliveryState.finished)
.where((d) {
final parent = d.findParentOfComponent(articleNumber);
if (parent == null) return false;
final comp = parent.findComponent(articleNumber);
return comp != null && !comp.isFullyScanned;
})
.toList();
if (componentDeliveries.isNotEmpty) {
if (componentDeliveries.length == 1) {
setState(() => _isScanning = true);
context.read<TourBloc>().add(ScanComponentEvent(
componentArticleNumber: articleNumber,
carId: _selectedCarId!.toString(),
deliveryId: componentDeliveries.first.id,
));
return;
}
_showCustomerSelectionSheet(
articleNumber,
componentDeliveries,
tourState.tour,
isComponent: true,
);
return;
}
// ── 2. Regular article scan ──
final needingDeliveries = tourState.tour.deliveries
.where((d) => d.state != DeliveryState.finished)
.where((d) => d.articles.any((a) =>
a.articleNumber == articleNumber &&
!a.isParent &&
a.scannedAmount + a.scannedRemovedAmount < a.amount))
.toList();
@ -189,8 +230,9 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
void _showCustomerSelectionSheet(
String articleNumber,
List<Delivery> deliveries,
Tour tour,
) {
Tour tour, {
bool isComponent = false,
}) {
final tourBloc = context.read<TourBloc>();
final carId = _selectedCarId!;
@ -244,11 +286,19 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
onTap: () {
Navigator.pop(ctx);
setState(() => _isScanning = true);
tourBloc.add(ScanArticleEvent(
articleNumber: articleNumber,
carId: carId.toString(),
deliveryId: delivery.id,
));
if (isComponent) {
tourBloc.add(ScanComponentEvent(
componentArticleNumber: articleNumber,
carId: carId.toString(),
deliveryId: delivery.id,
));
} else {
tourBloc.add(ScanArticleEvent(
articleNumber: articleNumber,
carId: carId.toString(),
deliveryId: delivery.id,
));
}
},
);
}),
@ -269,37 +319,23 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate;
}
List<_ArticleGroup> _buildArticleGroups(Tour tour) {
final Map<String, List<_ArticleDeliveryEntry>> grouped = {};
List<_DeliveryGroup> _buildDeliveryGroups(Tour tour) {
final List<_DeliveryGroup> groups = [];
for (final delivery in tour.deliveries) {
if (delivery.state == DeliveryState.finished) continue;
final carPlate = _lookupCarPlate(delivery.carId, tour);
for (final article in delivery.articles) {
if (!article.scannable) continue;
grouped.putIfAbsent(article.articleNumber, () => []);
grouped[article.articleNumber]!.add(
_ArticleDeliveryEntry(
delivery: delivery,
article: article,
carPlate: carPlate,
),
);
}
final scannableArticles =
delivery.articles.where((a) => a.scannable).toList();
if (scannableArticles.isEmpty) continue;
groups.add(_DeliveryGroup(
delivery: delivery,
articles: scannableArticles,
carPlate: _lookupCarPlate(delivery.carId, tour),
));
}
return grouped.entries.map((e) {
final entries = e.value;
return _ArticleGroup(
articleNumber: e.key,
name: entries.first.article.name,
totalAmount: entries.fold(0, (sum, e) => sum + e.article.amount),
totalScanned: entries.fold(0, (sum, e) => sum + e.article.scannedAmount),
totalRemoved: entries.fold(0, (sum, e) => sum + e.article.scannedRemovedAmount),
entries: entries,
);
}).toList()
..sort((a, b) => a.name.compareTo(b.name));
return groups;
}
// -------------------------------------------------------------------------
@ -335,7 +371,7 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
);
}
Widget _buildProgressHeader(List<_ArticleGroup> allGroups) {
Widget _buildProgressHeader(List<_DeliveryGroup> allGroups) {
final total = allGroups.length;
final done = allGroups.where((g) => g.isComplete).length;
final progress = total > 0 ? done / total : 0.0;
@ -355,7 +391,7 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
),
),
Text(
"$done / $total Artikel",
"$done / $total Kunden",
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@ -382,14 +418,9 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
);
}
Widget _buildArticleTile(_ArticleGroup group, {int? carIdFilter}) {
Widget _buildDeliveryTile(_DeliveryGroup group) {
final isComplete = group.isComplete;
final isPartial = group.scannedOrRemoved > 0 && !isComplete;
final entries = carIdFilter != null
? group.entries
.where((e) => e.delivery.carId == carIdFilter)
.toList()
: group.entries;
final isPartial = group.isPartial;
final Color cardColor;
final Color borderColor;
@ -434,11 +465,11 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
key: const ValueKey('done'),
)
: SizedBox(
width: 32,
width: 36,
key: const ValueKey('progress'),
child: Center(
child: Text(
'${group.scannedOrRemoved}/${group.totalAmount}',
'${group.completeArticles}/${group.totalArticles}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
@ -449,73 +480,154 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
),
),
title: Text(
group.name,
group.delivery.customer.name,
style: TextStyle(
fontWeight: FontWeight.w600,
color: titleColor,
),
),
subtitle: Text(
"Artikelnr. ${group.articleNumber}",
group.delivery.customer.address.toString(),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: group.carPlate != null
? _carBadge(context, group.carPlate!)
: null,
children: [
const Divider(height: 1, indent: 16, endIndent: 16),
...entries.map(_buildDeliveryEntry),
...group.articles.map(_buildArticleEntry),
const SizedBox(height: 4),
],
),
);
}
Widget _buildDeliveryEntry(_ArticleDeliveryEntry entry) {
final article = entry.article;
final customer = entry.delivery.customer;
final entryDone =
article.scannedAmount + article.scannedRemovedAmount >= article.amount;
Widget _buildArticleEntry(Article article) {
if (article.isParent && article.components.isNotEmpty) {
return _buildParentArticleEntry(article);
}
final entryDone = article.isFullyScanned;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 2),
leading: Icon(
entryDone ? Icons.check_circle_outline : Icons.person_outline,
entryDone ? Icons.check_circle_outline : Icons.inventory_2_outlined,
color: entryDone
? Colors.green
: Theme.of(context).colorScheme.onSurfaceVariant,
size: 20,
),
title: Text(
customer.name,
article.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
subtitle: Text(
customer.address.toString(),
"Artikelnr. ${article.articleNumber}",
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (entry.carPlate != null) ...[
_carBadge(context, entry.carPlate!),
const SizedBox(height: 4),
],
Text(
'${article.scannedAmount + article.scannedRemovedAmount} / ${article.amount}×',
trailing: Text(
'${article.scannedAmount + article.scannedRemovedAmount} / ${article.amount}×',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: entryDone
? Colors.green
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
/// Renders a parent article (Stückliste) with its components listed below.
Widget _buildParentArticleEntry(Article article) {
final allDone = article.isFullyScanned;
final scannedCount =
article.components.where((c) => c.isFullyScanned).length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 2),
leading: Icon(
allDone
? Icons.check_circle_outline
: Icons.account_tree_outlined,
color: allDone
? Colors.green
: Theme.of(context).colorScheme.primary,
size: 20,
),
title: Text(
article.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
subtitle: Text(
"Stückliste · $scannedCount/${article.components.length} Komponenten",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: entryDone
? Colors.green
: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
trailing: Icon(
allDone ? Icons.check_circle : Icons.pending_outlined,
color: allDone ? Colors.green : Colors.orange,
size: 18,
),
),
...article.components.map(_buildComponentEntry),
],
);
}
/// Single component row, indented below the parent article.
Widget _buildComponentEntry(Component component) {
final done = component.isFullyScanned;
return Padding(
padding: const EdgeInsets.only(left: 32),
child: ListTile(
dense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 0),
leading: Icon(
done
? Icons.check_circle_outline
: Icons.radio_button_unchecked,
color: done
? Colors.green
: Theme.of(context).colorScheme.onSurfaceVariant,
size: 16,
),
title: Text(
component.name,
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
"Artikelnr. ${component.articleNumber}",
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: Text(
'${component.scannedAmount} / ${component.requiredAmount}×',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: done
? Colors.green
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
@ -526,8 +638,8 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
Widget _buildOpenTab(
TourLoaded state,
List<_ArticleGroup> openGroups,
List<_ArticleGroup> allGroups,
List<_DeliveryGroup> openGroups,
List<_DeliveryGroup> allGroups,
bool useHardwareScanner,
) {
return Column(
@ -535,7 +647,31 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
if (_isScanning)
const LinearProgressIndicator(),
if (!useHardwareScanner && openGroups.isNotEmpty)
BarcodeScannerWidget(onBarcodeDetected: _handleBarcodeScanned),
Stack(
children: [
BarcodeScannerWidget(onBarcodeDetected: _handleBarcodeScanned),
if (state.pendingScanRequests > 0)
Positioned(
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
),
),
),
],
),
_buildProgressHeader(allGroups),
const Divider(height: 1),
Expanded(
@ -551,7 +687,7 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
),
const SizedBox(height: 12),
const Text(
"Alle Artikel geladen!",
"Alle Kunden vollständig beladen!",
style: TextStyle(fontSize: 16),
),
],
@ -561,14 +697,14 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
padding: const EdgeInsets.only(top: 8, bottom: 96),
itemCount: openGroups.length,
itemBuilder: (context, index) =>
_buildArticleTile(openGroups[index]),
_buildDeliveryTile(openGroups[index]),
),
),
],
);
}
Widget _buildLoadedTab(List<_ArticleGroup> loadedGroups) {
Widget _buildLoadedTab(List<_DeliveryGroup> loadedGroups) {
if (_selectedCarId == null) {
return const Center(child: Text("Kein Fahrzeug ausgewählt"));
}
@ -585,7 +721,7 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
),
const SizedBox(height: 12),
Text(
"Noch keine Artikel im Auto",
"Noch keine Kunden im Auto",
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
@ -599,10 +735,8 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
return ListView.builder(
padding: const EdgeInsets.only(top: 8, bottom: 96),
itemCount: loadedGroups.length,
itemBuilder: (context, index) => _buildArticleTile(
loadedGroups[index],
carIdFilter: _selectedCarId,
),
itemBuilder: (context, index) =>
_buildDeliveryTile(loadedGroups[index]),
);
}
@ -617,7 +751,7 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
builder: (context, carState) {
return BlocConsumer<TourBloc, TourState>(
listener: (context, tourState) {
if (tourState is TourLoaded) {
if (tourState is TourLoaded && tourState.pendingScanRequests == 0) {
setState(() => _isScanning = false);
}
},
@ -641,20 +775,19 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
));
}
final allGroups = _buildArticleGroups(tourState.tour);
final allGroups = _buildDeliveryGroups(tourState.tour);
// Offen: mindestens ein Kundeneintrag ist noch nicht vollständig gescannt
final openGroups = allGroups.where((g) => g.entries.any((e) =>
e.article.scannedAmount + e.article.scannedRemovedAmount <
e.article.amount,
)).toList();
// Offen: Lieferung hat noch mindestens einen nicht vollständig
// gescannten Artikel (über alle Autos hinweg).
final openGroups =
allGroups.where((g) => !g.isComplete).toList();
// Im Auto: mindestens ein Kundeneintrag für das aktuelle Auto ist vollständig
final loadedGroups = allGroups.where((g) => g.entries.any((e) =>
e.delivery.carId == _selectedCarId &&
e.article.scannedAmount + e.article.scannedRemovedAmount >=
e.article.amount,
)).toList();
// Im Auto: Lieferung des aktuellen Autos, bei der mindestens ein
// Stück gescannt wurde.
final loadedGroups = allGroups
.where((g) =>
g.delivery.carId == _selectedCarId && g.hasAnyScanned)
.toList();
final allDone = tourState.tour.deliveries.isNotEmpty &&
openGroups.isEmpty;

View File

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

34
lib/model/component.dart Normal file
View File

@ -0,0 +1,34 @@
import 'package:hl_lieferservice/dto/component.dart';
class Component {
Component({
required this.articleNumber,
required this.name,
required this.quantity,
required this.position,
this.scannedAmount = 0,
});
final String articleNumber;
final String name;
final double quantity;
final double position;
int scannedAmount;
/// Required scan count derived from BOM quantity (e.g. 7.0 → 7).
int get requiredAmount => quantity.ceil();
bool get isFullyScanned => scannedAmount >= requiredAmount;
bool get needsScanning => scannedAmount < requiredAmount;
factory Component.fromDTO(ComponentDTO dto) {
return Component(
articleNumber: dto.articleNr,
name: dto.name,
quantity: double.tryParse(dto.quantity) ?? 0.0,
position: double.tryParse(dto.pos) ?? 0.0,
);
}
}

View File

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

View File

@ -71,28 +71,31 @@ class _DeliveryAppState extends State<DeliveryApp> {
),
],
child: MaterialApp(
home: OperationViewEnforcer(
child: BlocBuilder<AppBloc, AppState>(
builder: (context, state) {
if (state is AppConfigLoading) {
return Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
// Wrap the Navigator (not just the home route) so the loading
// overlay covers every pushed route — DeliveryDetail, Cars,
// dialogs, etc. — not only the initial home tree.
builder: (context, child) =>
OperationViewEnforcer(child: child ?? const SizedBox.shrink()),
home: BlocBuilder<AppBloc, AppState>(
builder: (context, state) {
if (state is AppConfigLoading) {
return Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (state is AppConfigLoadingFailed) {
return Scaffold(body: Center(child: Text(state.message)));
}
if (state is AppConfigLoadingFailed) {
return Scaffold(body: Center(child: Text(state.message)));
}
if (state is AppConfigLoaded) {
return LoginEnforcer(
child: CarSelectionEnforcer(child: Home()),
);
}
if (state is AppConfigLoaded) {
return LoginEnforcer(
child: CarSelectionEnforcer(child: Home()),
);
}
return Container();
},
),
return Container();
},
),
routes: {"/cars": (context) => CarManagementPage()},
),

View File

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

View File

@ -1,5 +1,11 @@
abstract class OperationEvent {}
class StartOperation extends OperationEvent {
String? message;
StartOperation({this.message});
}
class FailOperation extends OperationEvent {
String message;

View File

@ -2,6 +2,12 @@ abstract class OperationState {}
class OperationIdle extends OperationState {}
class OperationInProgress extends OperationState {
String? message;
OperationInProgress({this.message});
}
class OperationFailed extends OperationState {
String message;

View File

@ -4,8 +4,11 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import '../bloc/operation_state.dart';
/// Listens to [OperationBloc] and shows SnackBars for success and error
/// messages. Loading indicators are handled locally by each feature.
/// Listens to [OperationBloc] and shows:
/// - SnackBars for success and error messages.
/// - A blocking modal barrier with a spinner while a mutation is in flight,
/// so the user gets unambiguous "wait" feedback and cannot double-tap or
/// navigate away mid-request.
class OperationViewEnforcer extends StatelessWidget {
final Widget child;
@ -13,7 +16,7 @@ class OperationViewEnforcer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocListener<OperationBloc, OperationState>(
return BlocConsumer<OperationBloc, OperationState>(
listener: (context, state) {
if (state is OperationFinished && state.message != null) {
ScaffoldMessenger.of(context).showSnackBar(
@ -27,7 +30,44 @@ class OperationViewEnforcer extends StatelessWidget {
);
}
},
child: child,
builder: (context, state) {
final isInProgress = state is OperationInProgress;
final progressMessage =
isInProgress ? state.message : null;
return Stack(
children: [
child,
if (isInProgress)
PopScope(
canPop: false,
child: Stack(
children: [
const ModalBarrier(
dismissible: false,
color: Colors.black54,
),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
if (progressMessage != null) ...[
const SizedBox(height: 16),
Text(
progressMessage,
style: const TextStyle(color: Colors.white),
),
],
],
),
),
],
),
),
],
);
},
);
}
}