From ac6b03227d23bb2205383d53b1f2300379dae1cf Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Mon, 11 May 2026 17:12:05 +0200 Subject: [PATCH] Added components to article --- lib/dto/article.dart | 10 + lib/dto/article.g.dart | 11 + lib/dto/component.dart | 23 + lib/dto/component.g.dart | 22 + .../authentication/bloc/auth_bloc.dart | 2 + .../authentication/bloc/auth_state.dart | 4 + .../presentation/login_page.dart | 41 +- lib/feature/car_selection/bloc/bloc.dart | 3 +- lib/feature/car_selection/bloc/events.dart | 14 +- .../presentation/car_selection_enforcer.dart | 19 +- .../presentation/car_selection_page.dart | 8 +- .../repository/car_selection_repository.dart | 30 +- lib/feature/cars/bloc/cars_bloc.dart | 3 + lib/feature/delivery/bloc/tour_bloc.dart | 105 ++++- lib/feature/delivery/bloc/tour_event.dart | 19 +- lib/feature/delivery/bloc/tour_state.dart | 33 +- .../delivery/detail/bloc/note_bloc.dart | 14 + .../article/article_reset_scan_dialog.dart | 6 +- .../article/article_unscan_dialog.dart | 6 +- .../presentation/delivery_detail_page.dart | 106 ++--- .../presentation/delivery_discount.dart | 19 +- .../detail/presentation/delivery_sign.dart | 433 ++++++++++-------- .../presentation/note/note_add_dialog.dart | 24 +- .../overview/presentation/delivery_item.dart | 9 +- .../overview/presentation/delivery_list.dart | 4 +- .../presentation/delivery_overview.dart | 2 - .../presentation/delivery_overview_page.dart | 43 +- .../delivery/repository/tour_repository.dart | 42 ++ lib/feature/scan/presentation/scan_page.dart | 367 ++++++++++----- lib/model/article.dart | 45 +- lib/model/component.dart | 34 ++ lib/model/delivery.dart | 27 +- lib/widget/app.dart | 41 +- .../operations/bloc/operation_bloc.dart | 73 ++- .../operations/bloc/operation_event.dart | 6 + .../operations/bloc/operation_state.dart | 6 + .../presentation/operation_view_enforcer.dart | 48 +- 37 files changed, 1189 insertions(+), 513 deletions(-) create mode 100644 lib/dto/component.dart create mode 100644 lib/dto/component.g.dart create mode 100644 lib/model/component.dart diff --git a/lib/dto/article.dart b/lib/dto/article.dart index afc1121..af233c4 100644 --- a/lib/dto/article.dart +++ b/lib/dto/article.dart @@ -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? components; + String? warehouseNr; + String? warehouseName; factory ArticleDTO.fromJson(Map json) => _$ArticleDTOFromJson(json); diff --git a/lib/dto/article.g.dart b/lib/dto/article.g.dart index a913f06..ca1fbc3 100644 --- a/lib/dto/article.g.dart +++ b/lib/dto/article.g.dart @@ -17,6 +17,13 @@ ArticleDTO _$ArticleDTOFromJson(Map 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?) + ?.map((e) => ComponentDTO.fromJson(e as Map)) + .toList(), + warehouseNr: json['warehouse_nr'] as String?, + warehouseName: json['warehouse_name'] as String?, ); Map _$ArticleDTOToJson(ArticleDTO instance) => @@ -31,4 +38,8 @@ Map _$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, }; diff --git a/lib/dto/component.dart b/lib/dto/component.dart new file mode 100644 index 0000000..bb8d33c --- /dev/null +++ b/lib/dto/component.dart @@ -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 json) => + _$ComponentDTOFromJson(json); + + Map toJson() => _$ComponentDTOToJson(this); +} diff --git a/lib/dto/component.g.dart b/lib/dto/component.g.dart new file mode 100644 index 0000000..8196616 --- /dev/null +++ b/lib/dto/component.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'component.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ComponentDTO _$ComponentDTOFromJson(Map json) => ComponentDTO( + articleNr: json['article_nr'] as String, + name: json['name'] as String, + quantity: json['quantity'] as String, + pos: json['pos'] as String, +); + +Map _$ComponentDTOToJson(ComponentDTO instance) => + { + 'article_nr': instance.articleNr, + 'name': instance.name, + 'quantity': instance.quantity, + 'pos': instance.pos, + }; diff --git a/lib/feature/authentication/bloc/auth_bloc.dart b/lib/feature/authentication/bloc/auth_bloc.dart index 78d02e0..bb2c02e 100644 --- a/lib/feature/authentication/bloc/auth_bloc.dart +++ b/lib/feature/authentication/bloc/auth_bloc.dart @@ -25,6 +25,7 @@ class AuthBloc extends Bloc { try { debugPrint("Retrieve user information"); + emit(Authenticating()); var response = await service.getUserinfo(event.sessionId); var state = Authenticated(sessionId: event.sessionId, user: response); locator.registerSingleton(state); @@ -34,6 +35,7 @@ class AuthBloc extends Bloc { debugPrint(err.toString()); debugPrint(st.toString()); + emit(Unauthenticated()); operationBloc.add( FailOperation( message: "Login war nicht erfolgreich. Probieren Sie es erneut.", diff --git a/lib/feature/authentication/bloc/auth_state.dart b/lib/feature/authentication/bloc/auth_state.dart index 3b2200a..9de5bbd 100644 --- a/lib/feature/authentication/bloc/auth_state.dart +++ b/lib/feature/authentication/bloc/auth_state.dart @@ -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; diff --git a/lib/feature/authentication/presentation/login_page.dart b/lib/feature/authentication/presentation/login_page.dart index 58b70da..354c4a2 100644 --- a/lib/feature/authentication/presentation/login_page.dart +++ b/lib/feature/authentication/presentation/login_page.dart @@ -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 { // 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 { 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( + 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...', + ), + ], + ); + }, ), ), ], diff --git a/lib/feature/car_selection/bloc/bloc.dart b/lib/feature/car_selection/bloc/bloc.dart index b8a79d0..3008a1e 100644 --- a/lib/feature/car_selection/bloc/bloc.dart +++ b/lib/feature/car_selection/bloc/bloc.dart @@ -24,7 +24,7 @@ class CarSelectBloc extends Bloc { 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 { try { final today = DateTime.now(); await repository.saveSelection( + event.userId, CarSelection( date: today, selectedCarId: event.car.id, diff --git a/lib/feature/car_selection/bloc/events.dart b/lib/feature/car_selection/bloc/events.dart index b77591a..8b7e9d6 100644 --- a/lib/feature/car_selection/bloc/events.dart +++ b/lib/feature/car_selection/bloc/events.dart @@ -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}); -} \ No newline at end of file +} diff --git a/lib/feature/car_selection/presentation/car_selection_enforcer.dart b/lib/feature/car_selection/presentation/car_selection_enforcer.dart index 54a85da..7022710 100644 --- a/lib/feature/car_selection/presentation/car_selection_enforcer.dart +++ b/lib/feature/car_selection/presentation/car_selection_enforcer.dart @@ -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 { @override void initState() { super.initState(); - context.read().add(CarSelectLoad()); + final authState = context.read().state; + if (authState is Authenticated) { + context + .read() + .add(CarSelectLoad(userId: authState.user.number)); + } } @override @@ -53,8 +60,14 @@ class _CarSelectionEnforcerState extends State { ), const SizedBox(height: 16), FilledButton( - onPressed: () => - context.read().add(CarSelectLoad()), + onPressed: () { + final authState = context.read().state; + if (authState is Authenticated) { + context.read().add( + CarSelectLoad(userId: authState.user.number), + ); + } + }, child: const Text("Erneut versuchen"), ), ], diff --git a/lib/feature/car_selection/presentation/car_selection_page.dart b/lib/feature/car_selection/presentation/car_selection_page.dart index 33d47d7..2f454d8 100644 --- a/lib/feature/car_selection/presentation/car_selection_page.dart +++ b/lib/feature/car_selection/presentation/car_selection_page.dart @@ -52,7 +52,13 @@ class _CarSelectionPageState extends State { void _onConfirm() { if (_selectedCar == null) return; - context.read().add(CarSelectConfirm(car: _selectedCar!)); + final authState = context.read().state as Authenticated; + context.read().add( + CarSelectConfirm( + userId: authState.user.number, + car: _selectedCar!, + ), + ); } Widget _buildCarList(List cars) { diff --git a/lib/feature/car_selection/repository/car_selection_repository.dart b/lib/feature/car_selection/repository/car_selection_repository.dart index f064ed7..9a456ab 100644 --- a/lib/feature/car_selection/repository/car_selection_repository.dart +++ b/lib/feature/car_selection/repository/car_selection_repository.dart @@ -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 getSelection() async { + /// Returns the stored [CarSelection] for the given user, or null if nothing + /// has been saved yet for that user. + Future 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 saveSelection(CarSelection selection) async { + /// Persists the given [selection] for the given user locally on this device. + Future 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!); } -} \ No newline at end of file +} diff --git a/lib/feature/cars/bloc/cars_bloc.dart b/lib/feature/cars/bloc/cars_bloc.dart index dac99e9..d01e390 100644 --- a/lib/feature/cars/bloc/cars_bloc.dart +++ b/lib/feature/cars/bloc/cars_bloc.dart @@ -51,6 +51,7 @@ class CarsBloc extends Bloc { Future _carAdd(CarAdd event, Emitter 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 { Future _carEdit(CarEdit event, Emitter emit) async { final currentState = state; + opBloc.add(StartOperation()); try { await repository.edit(event.teamId, event.newCar); @@ -98,6 +100,7 @@ class CarsBloc extends Bloc { Future _carDelete(CarDelete event, Emitter emit) async { final currentState = state; + opBloc.add(StartOperation()); try { await repository.delete(event.carId, event.teamId); diff --git a/lib/feature/delivery/bloc/tour_bloc.dart b/lib/feature/delivery/bloc/tour_bloc.dart index d0a95ce..c4d3e1b 100644 --- a/lib/feature/delivery/bloc/tour_bloc.dart +++ b/lib/feature/delivery/bloc/tour_bloc.dart @@ -43,6 +43,7 @@ class TourBloc extends Bloc { on(_assignCar); on(_increment); on(_scan); + on(_scanComponent); on(_holdDelivery); on(_cancelDelivery); on(_reactivateDelivery); @@ -82,6 +83,7 @@ class TourBloc extends Bloc { ) 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 { 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 { ) async { Map 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 { } 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 { tour: event.tour, paymentOptions: event.payments, sortingInformation: container, - distances: event.distances, ), ); + + add(RequestDeliveryDistanceEvent(tour: event.tour)); } void _updated(TourUpdated event, Emitter emit) { @@ -235,14 +229,14 @@ class TourBloc extends Bloc { paymentOptions: payments, distances: Map.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 { ) 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 { void _holdDelivery(HoldDeliveryEvent event, Emitter 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 { ) 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 { } } + void _bumpPendingScans(Emitter 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 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 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 { } 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 { final currentState = state; if (currentState is TourLoaded) { + _bumpPendingScans(emit, 1); try { await tourRepository.scanArticle( event.deliveryId, @@ -338,6 +389,8 @@ class TourBloc extends Bloc { } 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 { Future _assignCar(AssignCarEvent event, Emitter 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 { 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 { ); 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 { UpdateSelectedPaymentMethodEvent event, Emitter 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 { UpdateDeliveryOptionEvent event, Emitter 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 { UpdateDiscountEvent event, Emitter 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 { RemoveDiscountEvent event, Emitter 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 { } void _addDiscount(AddDiscountEvent event, Emitter 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 { } void _unscan(UnscanArticleEvent event, Emitter emit) async { + opBloc.add(StartOperation()); try { await tourRepository.unscan( event.deliveryId, @@ -471,6 +539,7 @@ class TourBloc extends Bloc { 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 { } void _resetAmount(ResetScanAmountEvent event, Emitter 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"); diff --git a/lib/feature/delivery/bloc/tour_event.dart b/lib/feature/delivery/bloc/tour_event.dart index 3bfd729..f0ceac1 100644 --- a/lib/feature/delivery/bloc/tour_event.dart +++ b/lib/feature/delivery/bloc/tour_event.dart @@ -15,20 +15,17 @@ class LoadTour extends TourEvent { class RequestDeliveryDistanceEvent extends TourEvent { Tour tour; - List payments; - RequestDeliveryDistanceEvent({required this.tour, required this.payments}); + RequestDeliveryDistanceEvent({required this.tour}); } class RequestSortingInformationEvent extends TourEvent { Tour tour; List payments; - Map? 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; diff --git a/lib/feature/delivery/bloc/tour_state.dart b/lib/feature/delivery/bloc/tour_state.dart index ef4e560..eedc1c6 100644 --- a/lib/feature/delivery/bloc/tour_state.dart +++ b/lib/feature/delivery/bloc/tour_state.dart @@ -8,49 +8,38 @@ class TourLoading extends TourState {} class TourLoadingFailed extends TourState {} -class TourRequestingDistances extends TourState { - Tour tour; - List payments; - - TourRequestingDistances({required this.tour, required this.payments}); -} - -class TourRequestingSortingInformation extends TourState { - Tour tour; - Map? distances; - List paymentOptions; - - TourRequestingSortingInformation({ - required this.tour, - this.distances, - required this.paymentOptions, - }); -} - class TourLoaded extends TourState { Tour tour; Map? distances; List paymentOptions; Map> 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? distances, List? paymentOptions, - Map>? sortingInformation + Map>? 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, ); } } diff --git a/lib/feature/delivery/detail/bloc/note_bloc.dart b/lib/feature/delivery/detail/bloc/note_bloc.dart index 6fa8bb9..d2c88bc 100644 --- a/lib/feature/delivery/detail/bloc/note_bloc.dart +++ b/lib/feature/delivery/detail/bloc/note_bloc.dart @@ -94,8 +94,10 @@ class NoteBloc extends Bloc { RemoveImageNote event, Emitter 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 { } Future _upload(AddImageNote event, Emitter 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 { } Future _load(LoadNote event, Emitter emit) async { + if (state is NoteLoaded || state is NoteLoading) { + return; + } + emit.call(NoteLoading()); try { @@ -130,8 +138,10 @@ class NoteBloc extends Bloc { } Future _add(AddNote event, Emitter 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 { } Future _edit(EditNote event, Emitter 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 { } Future _remove(RemoveNote event, Emitter 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"); diff --git a/lib/feature/delivery/detail/presentation/article/article_reset_scan_dialog.dart b/lib/feature/delivery/detail/presentation/article/article_reset_scan_dialog.dart index b034eff..5906e97 100644 --- a/lib/feature/delivery/detail/presentation/article/article_reset_scan_dialog.dart +++ b/lib/feature/delivery/detail/presentation/article/article_reset_scan_dialog.dart @@ -91,8 +91,10 @@ class _ResetArticleAmountDialogState extends State { 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, diff --git a/lib/feature/delivery/detail/presentation/article/article_unscan_dialog.dart b/lib/feature/delivery/detail/presentation/article/article_unscan_dialog.dart index 7e037a7..bff73f8 100644 --- a/lib/feature/delivery/detail/presentation/article/article_unscan_dialog.dart +++ b/lib/feature/delivery/detail/presentation/article/article_unscan_dialog.dart @@ -154,8 +154,10 @@ class _ArticleUnscanDialogState extends State { ], ), ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, + Wrap( + spacing: 10, + runSpacing: 8, + alignment: WrapAlignment.spaceAround, children: [ FilledButton( onPressed: isValidText ? _unscan : null, diff --git a/lib/feature/delivery/detail/presentation/delivery_detail_page.dart b/lib/feature/delivery/detail/presentation/delivery_detail_page.dart index f5801f2..ffbb309 100644 --- a/lib/feature/delivery/detail/presentation/delivery_detail_page.dart +++ b/lib/feature/delivery/detail/presentation/delivery_detail_page.dart @@ -142,66 +142,70 @@ class _DeliveryDetailState extends State { } 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( - builder: (context, state) { - final currentState = state; + return BlocBuilder( + 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), + ); + }, ); } } diff --git a/lib/feature/delivery/detail/presentation/delivery_discount.dart b/lib/feature/delivery/detail/presentation/delivery_discount.dart index e96b998..d6112ba 100644 --- a/lib/feature/delivery/detail/presentation/delivery_discount.dart +++ b/lib/feature/delivery/detail/presentation/delivery_discount.dart @@ -195,17 +195,16 @@ class _DeliveryDiscountState extends State { }, ), ), - 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, diff --git a/lib/feature/delivery/detail/presentation/delivery_sign.dart b/lib/feature/delivery/detail/presentation/delivery_sign.dart index a3d322f..de4cf0a 100644 --- a/lib/feature/delivery/detail/presentation/delivery_sign.dart +++ b/lib/feature/delivery/detail/presentation/delivery_sign.dart @@ -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 { 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().add(LoadNote(delivery: widget.delivery)); } @@ -80,14 +60,88 @@ class _SignatureViewState extends State { 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 _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 { style: Theme.of(context).textTheme.headlineSmall, ), ), - BlocConsumer( - 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( + 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 { fontWeight: FontWeight.bold, ), ), - Expanded(child: _signatureField()), + Expanded( + child: Signature( + controller: widget.controller, + backgroundColor: Colors.white, + ), + ), ], ), ), @@ -285,36 +352,22 @@ class _SignatureViewState extends State { ), ), ), - _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), + ), + ), + ), + ), ); } } diff --git a/lib/feature/delivery/detail/presentation/note/note_add_dialog.dart b/lib/feature/delivery/detail/presentation/note/note_add_dialog.dart index a0ca58c..daa50f8 100644 --- a/lib/feature/delivery/detail/presentation/note/note_add_dialog.dart +++ b/lib/feature/delivery/detail/presentation/note/note_add_dialog.dart @@ -51,6 +51,10 @@ class _NoteAddDialogState extends State { @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 { maxLines: 10, ), ), - Row( - mainAxisAlignment: MainAxisAlignment.start, + Wrap( + spacing: 10, + runSpacing: 8, children: [ FilledButton( onPressed: @@ -126,15 +131,12 @@ class _NoteAddDialogState extends State { : 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"), ), ], ), diff --git a/lib/feature/delivery/overview/presentation/delivery_item.dart b/lib/feature/delivery/overview/presentation/delivery_item.dart index cda2de4..c1a9dff 100644 --- a/lib/feature/delivery/overview/presentation/delivery_item.dart +++ b/lib/feature/delivery/overview/presentation/delivery_item.dart @@ -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, ); } } diff --git a/lib/feature/delivery/overview/presentation/delivery_list.dart b/lib/feature/delivery/overview/presentation/delivery_list.dart index 5d50c6e..b9b6f98 100644 --- a/lib/feature/delivery/overview/presentation/delivery_list.dart +++ b/lib/feature/delivery/overview/presentation/delivery_list.dart @@ -43,7 +43,7 @@ class _DeliveryListState extends State { 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 { itemCount: sorted.length, itemBuilder: (context, index) => DeliveryListItem( delivery: sorted[index], - distance: currentState.distances?[sorted[index].id] ?? 0.0, + distance: currentState.distances?[sorted[index].id], ), ); } diff --git a/lib/feature/delivery/overview/presentation/delivery_overview.dart b/lib/feature/delivery/overview/presentation/delivery_overview.dart index 8c48c3a..1edec11 100644 --- a/lib/feature/delivery/overview/presentation/delivery_overview.dart +++ b/lib/feature/delivery/overview/presentation/delivery_overview.dart @@ -18,11 +18,9 @@ class DeliveryOverview extends StatefulWidget { const DeliveryOverview({ super.key, required this.tour, - required this.distances, }); final Tour tour; - final Map distances; @override State createState() => _DeliveryOverviewState(); diff --git a/lib/feature/delivery/overview/presentation/delivery_overview_page.dart b/lib/feature/delivery/overview/presentation/delivery_overview_page.dart index 14aec63..af8077c 100644 --- a/lib/feature/delivery/overview/presentation/delivery_overview_page.dart +++ b/lib/feature/delivery/overview/presentation/delivery_overview_page.dart @@ -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 { + 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().state; @@ -54,10 +84,13 @@ class _DeliveryOverviewPageState extends State { body: BlocBuilder( 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) { diff --git a/lib/feature/delivery/repository/tour_repository.dart b/lib/feature/delivery/repository/tour_repository.dart index abc5724..ac00931 100644 --- a/lib/feature/delivery/repository/tour_repository.dart +++ b/lib/feature/delivery/repository/tour_repository.dart @@ -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 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 unscan( String deliveryId, String articleId, diff --git a/lib/feature/scan/presentation/scan_page.dart b/lib/feature/scan/presentation/scan_page.dart index 5dca90a..a5e3621 100644 --- a/lib/feature/scan/presentation/scan_page.dart +++ b/lib/feature/scan/presentation/scan_page.dart @@ -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
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 with SingleTickerProviderStateMixin /// `;;`. /// 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 with SingleTickerProviderStateMixin final tourState = context.read().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().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 with SingleTickerProviderStateMixin void _showCustomerSelectionSheet( String articleNumber, List deliveries, - Tour tour, - ) { + Tour tour, { + bool isComponent = false, + }) { final tourBloc = context.read(); final carId = _selectedCarId!; @@ -244,11 +286,19 @@ class _ScanPageState extends State 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 with SingleTickerProviderStateMixin return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate; } - List<_ArticleGroup> _buildArticleGroups(Tour tour) { - final Map> 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 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 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 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 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 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 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 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 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 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 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 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 with SingleTickerProviderStateMixin builder: (context, carState) { return BlocConsumer( 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 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; diff --git a/lib/model/article.dart b/lib/model/article.dart index 6a45309..3a325ed 100644 --- a/lib/model/article.dart +++ b/lib/model/article.dart @@ -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 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, ); } } diff --git a/lib/model/component.dart b/lib/model/component.dart new file mode 100644 index 0000000..7c7694b --- /dev/null +++ b/lib/model/component.dart @@ -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, + ); + } +} diff --git a/lib/model/delivery.dart b/lib/model/delivery.dart index eea5abc..5f08f73 100644 --- a/lib/model/delivery.dart +++ b/lib/model/delivery.dart @@ -295,29 +295,42 @@ class Delivery implements Comparable { List
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
getScannableArticles() { return articles.where((article) => article.scannable).toList(); } bool allArticlesScanned() { return getScannableArticles().every( - (article) => - article.amount == - article.scannedAmount + article.scannedRemovedAmount, + (article) => article.isFullyScanned, ); } diff --git a/lib/widget/app.dart b/lib/widget/app.dart index 03927f7..a43d7cf 100644 --- a/lib/widget/app.dart +++ b/lib/widget/app.dart @@ -71,28 +71,31 @@ class _DeliveryAppState extends State { ), ], child: MaterialApp( - home: OperationViewEnforcer( - child: BlocBuilder( - 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( + 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()}, ), diff --git a/lib/widget/operations/bloc/operation_bloc.dart b/lib/widget/operations/bloc/operation_bloc.dart index afaf33a..ba4434d 100644 --- a/lib/widget/operations/bloc/operation_bloc.dart +++ b/lib/widget/operations/bloc/operation_bloc.dart @@ -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 { + /// 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); on(_failOperation); on(_finishOperation); } - Future _failOperation(FailOperation event, Emitter emit) async { - emit(OperationFailed(message: event.message)); - await Future.delayed(const Duration(seconds: 5)); - emit(OperationIdle()); + Future _startOperation( + StartOperation event, + Emitter emit, + ) async { + if (_inFlightCount == 0) { + _overlayStartedAt = DateTime.now(); + } + _inFlightCount += 1; + emit(OperationInProgress(message: event.message)); } - Future _finishOperation(FinishOperation event, Emitter emit) async { - emit(OperationFinished(message: event.message)); + Future _finishOperation( + FinishOperation event, + Emitter 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 _failOperation( + FailOperation event, + Emitter 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 _awaitMinimumOverlayDuration() async { + final startedAt = _overlayStartedAt; + if (startedAt == null) return; + final elapsed = DateTime.now().difference(startedAt); + if (elapsed < _minimumDisplayDuration) { + await Future.delayed(_minimumDisplayDuration - elapsed); + } } } diff --git a/lib/widget/operations/bloc/operation_event.dart b/lib/widget/operations/bloc/operation_event.dart index 9d77652..c624605 100644 --- a/lib/widget/operations/bloc/operation_event.dart +++ b/lib/widget/operations/bloc/operation_event.dart @@ -1,5 +1,11 @@ abstract class OperationEvent {} +class StartOperation extends OperationEvent { + String? message; + + StartOperation({this.message}); +} + class FailOperation extends OperationEvent { String message; diff --git a/lib/widget/operations/bloc/operation_state.dart b/lib/widget/operations/bloc/operation_state.dart index 201fc3c..a52ea19 100644 --- a/lib/widget/operations/bloc/operation_state.dart +++ b/lib/widget/operations/bloc/operation_state.dart @@ -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; diff --git a/lib/widget/operations/presentation/operation_view_enforcer.dart b/lib/widget/operations/presentation/operation_view_enforcer.dart index ece8e27..bda7abe 100644 --- a/lib/widget/operations/presentation/operation_view_enforcer.dart +++ b/lib/widget/operations/presentation/operation_view_enforcer.dart @@ -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( + return BlocConsumer( 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), + ), + ], + ], + ), + ), + ], + ), + ), + ], + ); + }, ); } }