Added components to article

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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