Added components to article
This commit is contained in:
@ -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);
|
||||
|
||||
@ -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
23
lib/dto/component.dart
Normal file
@ -0,0 +1,23 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'component.g.dart';
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||
class ComponentDTO {
|
||||
ComponentDTO({
|
||||
required this.articleNr,
|
||||
required this.name,
|
||||
required this.quantity,
|
||||
required this.pos,
|
||||
});
|
||||
|
||||
String articleNr;
|
||||
String name;
|
||||
String quantity;
|
||||
String pos;
|
||||
|
||||
factory ComponentDTO.fromJson(Map<String, dynamic> json) =>
|
||||
_$ComponentDTOFromJson(json);
|
||||
|
||||
Map<dynamic, dynamic> toJson() => _$ComponentDTOToJson(this);
|
||||
}
|
||||
22
lib/dto/component.g.dart
Normal file
22
lib/dto/component.g.dart
Normal file
@ -0,0 +1,22 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'component.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
ComponentDTO _$ComponentDTOFromJson(Map<String, dynamic> json) => ComponentDTO(
|
||||
articleNr: json['article_nr'] as String,
|
||||
name: json['name'] as String,
|
||||
quantity: json['quantity'] as String,
|
||||
pos: json['pos'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ComponentDTOToJson(ComponentDTO instance) =>
|
||||
<String, dynamic>{
|
||||
'article_nr': instance.articleNr,
|
||||
'name': instance.name,
|
||||
'quantity': instance.quantity,
|
||||
'pos': instance.pos,
|
||||
};
|
||||
@ -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.",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
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"),
|
||||
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...',
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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!);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -142,7 +142,9 @@ class _DeliveryDetailState extends State<DeliveryDetail> {
|
||||
}
|
||||
|
||||
Widget _stepsNavigation(Delivery delivery) {
|
||||
return SizedBox(
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 90,
|
||||
child: Row(
|
||||
@ -170,22 +172,26 @@ class _DeliveryDetailState extends State<DeliveryDetail> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<TourBloc, TourState>(
|
||||
builder: (context, state) {
|
||||
Delivery? delivery;
|
||||
if (state is TourLoaded) {
|
||||
delivery = state.tour.deliveries.firstWhere(
|
||||
(d) => d.id == widget.deliveryId,
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Auslieferungsdetails")),
|
||||
body: BlocBuilder<TourBloc, TourState>(
|
||||
builder: (context, state) {
|
||||
final currentState = state;
|
||||
|
||||
if (currentState is TourLoaded) {
|
||||
Delivery delivery = currentState.tour.deliveries.firstWhere(
|
||||
(delivery) => delivery.id == widget.deliveryId,
|
||||
);
|
||||
return Column(
|
||||
body: delivery == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
children: [
|
||||
_stepInfo(),
|
||||
const Divider(),
|
||||
@ -194,14 +200,12 @@ class _DeliveryDetailState extends State<DeliveryDetail> {
|
||||
StepFactory().make(_step, delivery) ??
|
||||
_stepMissingWarning(),
|
||||
),
|
||||
_stepsNavigation(delivery),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
),
|
||||
bottomNavigationBar:
|
||||
delivery == null ? null : _stepsNavigation(delivery),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,18 +195,17 @@ class _DeliveryDiscountState extends State<DeliveryDiscount> {
|
||||
},
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: FilledButton(
|
||||
FilledButton(
|
||||
onPressed:
|
||||
!_isReasonEmpty && _discountValue > 0
|
||||
? _updateValues
|
||||
: null,
|
||||
child: const Text("Speichern"),
|
||||
),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: _discountValue > 0 && widget.discount != null ? _resetValues : null,
|
||||
child: const Text("Gutschrift entfernen"),
|
||||
|
||||
@ -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,68 +152,32 @@ 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(
|
||||
@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(),
|
||||
child: _notes(noteState),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 25.0, bottom: 0),
|
||||
@ -167,8 +185,7 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _noteAccepted,
|
||||
onChanged:
|
||||
_notesEmpty
|
||||
onChanged: notesEmpty
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
@ -178,7 +195,9 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
),
|
||||
Flexible(
|
||||
child: InkWell(
|
||||
onTap: _notesEmpty ? null : () {
|
||||
onTap: notesEmpty
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_noteAccepted = !_noteAccepted;
|
||||
});
|
||||
@ -221,40 +240,83 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Container();
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 90,
|
||||
child: Center(
|
||||
child: FilledButton(
|
||||
onPressed: isButtonEnabled ? widget.onContinue : null,
|
||||
child: const Text("Unterschreiben"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,16 +131,13 @@ class _NoteAddDialogState extends State<NoteAddDialog> {
|
||||
: null,
|
||||
child: const Text("Hinzufügen"),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: OutlinedButton(
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
_noteController.clear();
|
||||
_noteSelectionController.clear();
|
||||
},
|
||||
child: const Text("Zurücksetzen"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,11 +84,14 @@ class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
|
||||
body: BlocBuilder<TourBloc, TourState>(
|
||||
builder: (context, state) {
|
||||
if (state is TourLoaded) {
|
||||
return DeliveryOverview(
|
||||
if (state.distances == null) {
|
||||
return _buildOverviewWithBanner(
|
||||
tour: state.tour,
|
||||
distances: state.distances ?? {},
|
||||
bannerText: "Berechne Distanzen…",
|
||||
);
|
||||
}
|
||||
return DeliveryOverview(tour: state.tour);
|
||||
}
|
||||
|
||||
if (state is TourLoadingFailed) {
|
||||
return DeliveryLoadingFailedPage();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
int get totalArticles => articles.length;
|
||||
|
||||
int get completeArticles => articles
|
||||
.where((a) => a.isFullyScanned)
|
||||
.length;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
class _ArticleGroup {
|
||||
final String articleNumber;
|
||||
final String name;
|
||||
final int totalAmount;
|
||||
final int totalScanned;
|
||||
final int totalRemoved;
|
||||
final List<_ArticleDeliveryEntry> entries;
|
||||
|
||||
bool get isComplete => totalScanned + totalRemoved >= totalAmount;
|
||||
int get scannedOrRemoved => totalScanned + totalRemoved;
|
||||
|
||||
const _ArticleGroup({
|
||||
required this.articleNumber,
|
||||
required this.name,
|
||||
required this.totalAmount,
|
||||
required this.totalScanned,
|
||||
required this.totalRemoved,
|
||||
required this.entries,
|
||||
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);
|
||||
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(
|
||||
final scannableArticles =
|
||||
delivery.articles.where((a) => a.scannable).toList();
|
||||
if (scannableArticles.isEmpty) continue;
|
||||
|
||||
groups.add(_DeliveryGroup(
|
||||
delivery: delivery,
|
||||
article: article,
|
||||
carPlate: carPlate,
|
||||
),
|
||||
);
|
||||
}
|
||||
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,63 +480,59 @@ 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(
|
||||
trailing: Text(
|
||||
'${article.scannedAmount + article.scannedRemovedAmount} / ${article.amount}×',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
@ -515,7 +542,92 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
|
||||
: 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(
|
||||
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)
|
||||
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;
|
||||
|
||||
@ -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
34
lib/model/component.dart
Normal file
@ -0,0 +1,34 @@
|
||||
import 'package:hl_lieferservice/dto/component.dart';
|
||||
|
||||
class Component {
|
||||
Component({
|
||||
required this.articleNumber,
|
||||
required this.name,
|
||||
required this.quantity,
|
||||
required this.position,
|
||||
this.scannedAmount = 0,
|
||||
});
|
||||
|
||||
final String articleNumber;
|
||||
final String name;
|
||||
final double quantity;
|
||||
final double position;
|
||||
|
||||
int scannedAmount;
|
||||
|
||||
/// Required scan count derived from BOM quantity (e.g. 7.0 → 7).
|
||||
int get requiredAmount => quantity.ceil();
|
||||
|
||||
bool get isFullyScanned => scannedAmount >= requiredAmount;
|
||||
|
||||
bool get needsScanning => scannedAmount < requiredAmount;
|
||||
|
||||
factory Component.fromDTO(ComponentDTO dto) {
|
||||
return Component(
|
||||
articleNumber: dto.articleNr,
|
||||
name: dto.name,
|
||||
quantity: double.tryParse(dto.quantity) ?? 0.0,
|
||||
position: double.tryParse(dto.pos) ?? 0.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -71,8 +71,12 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: OperationViewEnforcer(
|
||||
child: BlocBuilder<AppBloc, AppState>(
|
||||
// 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(
|
||||
@ -93,7 +97,6 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
||||
return Container();
|
||||
},
|
||||
),
|
||||
),
|
||||
routes: {"/cars": (context) => CarManagementPage()},
|
||||
),
|
||||
);
|
||||
|
||||
@ -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 {
|
||||
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));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
abstract class OperationEvent {}
|
||||
|
||||
class StartOperation extends OperationEvent {
|
||||
String? message;
|
||||
|
||||
StartOperation({this.message});
|
||||
}
|
||||
|
||||
class FailOperation extends OperationEvent {
|
||||
String message;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user