Final commit.

This commit is contained in:
Dennis Nemec
2026-06-01 17:12:28 +02:00
parent 3ecbc82885
commit a9bf8ecdd1
385 changed files with 29081 additions and 12089 deletions

View File

@ -1,56 +1,129 @@
import 'dart:typed_data';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_state.dart';
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:intl/intl.dart';
import 'package:signature/signature.dart';
enum _SigningPhase { customerAcceptance, customerSignature, driverSignature }
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
/// Daten, die der Abschluss-Flow an den Aufrufer zurückgibt: beide
/// Unterschriften als PNG plus die dokumentierten Bestätigungen des Kunden.
class SignatureResult {
const SignatureResult({
required this.customerSignaturePng,
required this.driverSignaturePng,
required this.receiptConfirmed,
required this.notesAcknowledged,
required this.acknowledgedNoteIds,
required this.paymentCollected,
});
final Uint8List customerSignaturePng;
final Uint8List driverSignaturePng;
final bool receiptConfirmed;
final bool notesAcknowledged;
final List<String> acknowledgedNoteIds;
/// Fahrer hat das Inkasso (Bar/EC) des offenen Betrags bestätigt. `false`,
/// wenn kein Inkasso anfiel (offen == 0 oder „Auf Rechnung").
final bool paymentCollected;
}
/// Mehrstufiger Unterschrift-Flow zum Abschließen einer Lieferung.
///
/// Stufe 0 (Fahrer, optional): nur wenn beim Abschluss ein offener Betrag
/// per Vor-Ort-Inkasso (Bar/EC) zu kassieren ist ([requiresCollection]).
/// Der Fahrer bestätigt, dass der Betrag erhalten/abgerechnet wurde — VOR
/// beiden Unterschriften.
/// Stufe 1 (Kunde): sieht die Anmerkungen zur Lieferung, hakt zwei
/// Bestätigungen ab (Anmerkungen-Kenntnisnahme — nur Pflicht, wenn Notizen
/// vorhanden; Empfangsbestätigung — immer Pflicht) und unterschreibt.
/// Stufe 2 (Fahrer): unterschreibt.
///
/// Erst nach beiden Unterschriften ruft die View [onSigned] mit dem
/// vollständigen [SignatureResult] auf — der Aufrufer triggert dann den
/// Backend-Abschluss und schließt die Seite.
class SignatureView extends StatefulWidget {
const SignatureView({
super.key,
required this.onSigned,
required this.delivery,
required this.details,
required this.onSigned,
this.requiresCollection = false,
this.openAmount = 0,
this.paymentMethodLabel = '',
});
final Delivery delivery;
final TourDetails details;
final void Function(SignatureResult result) onSigned;
/// Callback that is called when the user has signed.
/// The parameter stores the path to the image file of the signature.
final void Function(
Uint8List customerSignaturePng,
Uint8List driverSignaturePng,
)
onSigned;
/// Offener Betrag muss vor Ort kassiert werden (offen > 0 UND Bar/EC).
/// Schaltet Stufe 0 (Inkasso-Bestätigung) frei.
final bool requiresCollection;
/// Offener Betrag in Euro (nur für die Anzeige in Stufe 0).
final double openAmount;
/// Anzeigename der Zahlungsmethode (z. B. „Bar", „EC-Karte").
final String paymentMethodLabel;
@override
State<StatefulWidget> createState() => _SignatureViewState();
State<SignatureView> createState() => _SignatureViewState();
}
/// Stufen des Abschluss-Flows.
enum _SignStage { payment, customer, driver }
class _SignatureViewState extends State<SignatureView> {
static const String _receiptText =
'Ich bestätige, dass ich die Ware im ordnungsgemäßen Zustand erhalten '
'habe und, dass die Aufstell- und Einbauarbeiten korrekt durchgeführt '
'wurden.';
static const String _notesText =
'Ich nehme die oben genannten Anmerkungen zur Lieferung zur Kenntnis.';
final SignatureController _customerController = SignatureController(
penStrokeWidth: 5,
penStrokeWidth: 3,
penColor: Colors.black,
exportBackgroundColor: Colors.white,
);
final SignatureController _driverController = SignatureController(
penStrokeWidth: 5,
penStrokeWidth: 3,
penColor: Colors.black,
exportBackgroundColor: Colors.white,
);
_SigningPhase _phase = _SigningPhase.customerAcceptance;
late final List<DeliveryNote> _notes;
late _SignStage _stage;
bool _paymentConfirmed = false;
bool _receiptAccepted = false;
bool _notesAccepted = false;
bool _customerEmpty = true;
bool _driverEmpty = true;
bool get _notesEmpty => _notes.isEmpty;
@override
void initState() {
super.initState();
context.read<NoteBloc>().add(LoadNote(delivery: widget.delivery));
// Inkasso-Bestätigung (Stufe 0) nur wenn gefordert, sonst direkt zum Kunden.
_stage =
widget.requiresCollection ? _SignStage.payment : _SignStage.customer;
_notes = widget.details.notesByDeliveryId[widget.delivery.id] ??
const <DeliveryNote>[];
_customerController.addListener(() {
if (_customerEmpty != _customerController.isEmpty) {
setState(() => _customerEmpty = _customerController.isEmpty);
}
});
_driverController.addListener(() {
if (_driverEmpty != _driverController.isEmpty) {
setState(() => _driverEmpty = _driverController.isEmpty);
}
});
}
@override
@ -60,314 +133,333 @@ class _SignatureViewState extends State<SignatureView> {
super.dispose();
}
void _onAcceptanceDone() {
setState(() => _phase = _SigningPhase.customerSignature);
}
bool get _customerStepValid =>
_receiptAccepted && (_notesAccepted || _notesEmpty) && !_customerEmpty;
void _onCustomerSigned() {
setState(() => _phase = _SigningPhase.driverSignature);
}
Future<void> _onDriverSigned() async {
widget.onSigned(
(await _customerController.toPngBytes())!,
(await _driverController.toPngBytes())!,
);
}
@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()),
);
/// Ist der Primär-Button auf der aktuellen Stufe aktiv?
bool get _stageValid {
switch (_stage) {
case _SignStage.payment:
return _paymentConfirmed;
case _SignStage.customer:
return _customerStepValid;
case _SignStage.driver:
return !_driverEmpty;
}
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: [
Padding(
padding: const EdgeInsets.only(bottom: 15),
child: Text(
"Notizen",
style: Theme.of(context).textTheme.headlineSmall,
),
),
_notesContent(noteState),
const Padding(padding: EdgeInsets.only(top: 25), child: Divider()),
],
);
}
@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,
),
),
),
],
),
),
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"),
),
),
),
Future<void> _onPrimaryPressed() async {
switch (_stage) {
case _SignStage.payment:
setState(() => _stage = _SignStage.customer);
return;
case _SignStage.customer:
setState(() => _stage = _SignStage.driver);
return;
case _SignStage.driver:
final customerPng = await _customerController.toPngBytes();
final driverPng = await _driverController.toPngBytes();
if (customerPng == null || driverPng == null) return;
widget.onSigned(
SignatureResult(
customerSignaturePng: customerPng,
driverSignaturePng: driverPng,
receiptConfirmed: _receiptAccepted,
notesAcknowledged: _notesEmpty ? false : _notesAccepted,
acknowledgedNoteIds:
_notesEmpty ? const [] : _notes.map((n) => n.id).toList(),
paymentCollected: widget.requiresCollection && _paymentConfirmed,
),
);
},
);
}
}
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();
String get _paymentText {
final amount = widget.openAmount.toStringAsFixed(2).replaceAll('.', ',');
final via = widget.paymentMethodLabel.isEmpty
? ''
: ' per ${widget.paymentMethodLabel}';
return 'Ich bestätige, dass der offene Betrag von $amount$via '
'erhalten bzw. abgerechnet wurde.';
}
@override
Widget build(BuildContext context) {
final formattedDate = DateFormat("dd.MM.yyyy").format(DateTime.now());
final theme = Theme.of(context);
final customer = widget.details.customerOf(widget.delivery);
final date = DateFormat('dd.MM.yyyy').format(DateTime.now());
final isPayment = _stage == _SignStage.payment;
final isDriver = _stage == _SignStage.driver;
final String title;
switch (_stage) {
case _SignStage.payment:
title = 'Zahlung bestätigen';
case _SignStage.customer:
title = 'Unterschrift des Kunden';
case _SignStage.driver:
title = 'Unterschrift des Fahrers';
}
return Scaffold(
appBar: AppBar(title: Text(widget.appBarTitle)),
body: Padding(
padding: const EdgeInsets.all(20.0),
appBar: AppBar(title: Text(title)),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
SizedBox(
width: double.infinity,
height: MediaQuery.of(context).size.height * 0.75,
child: DecoratedBox(
decoration: const BoxDecoration(color: Colors.white),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"Lieferung an: ${widget.delivery.customer.name}",
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
Expanded(
child: Signature(
controller: widget.controller,
backgroundColor: Colors.white,
),
),
],
),
),
const Divider(),
Text(
"${widget.delivery.customer.address.city}, den $formattedDate",
),
],
if (isPayment) ...[
// Stufe 0 — Fahrer kassiert (Bar/EC) und bestätigt VOR den
// Unterschriften.
_PaymentDueCard(
openAmount: widget.openAmount,
methodLabel: widget.paymentMethodLabel,
),
const SizedBox(height: 16),
_ConfirmTile(
value: _paymentConfirmed,
enabled: true,
label: _paymentText,
onChanged: (v) => setState(() => _paymentConfirmed = v),
),
] else ...[
if (!isDriver) ...[
_NotesSection(notes: _notes),
const SizedBox(height: 16),
_ConfirmTile(
value: _notesAccepted,
enabled: !_notesEmpty,
label: _notesText,
onChanged: (v) => setState(() => _notesAccepted = v),
),
_ConfirmTile(
value: _receiptAccepted,
enabled: true,
label: _receiptText,
onChanged: (v) => setState(() => _receiptAccepted = v),
),
const SizedBox(height: 16),
],
Text(
'Lieferung an: ${customer?.name ?? '⟨Unbekannter Kunde⟩'}',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
_SignaturePad(
controller: isDriver ? _driverController : _customerController,
onClear: () =>
(isDriver ? _driverController : _customerController)
.clear(),
),
const SizedBox(height: 4),
Text(
'${customer?.address.city ?? ''}, den $date',
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _stageValid ? _onPrimaryPressed : null,
icon: Icon(isDriver ? Icons.check : Icons.arrow_forward),
label: Text(isDriver ? 'Abschließen' : 'Weiter'),
),
],
),
),
);
}
}
// ─── Inkasso-Hinweis (Stufe 0) ──────────────────────────────────────────────
class _PaymentDueCard extends StatelessWidget {
const _PaymentDueCard({required this.openAmount, required this.methodLabel});
final double openAmount;
final String methodLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final amount = openAmount.toStringAsFixed(2).replaceAll('.', ',');
return Card(
margin: EdgeInsets.zero,
color: theme.colorScheme.primary.withValues(alpha: 0.07),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Offener Betrag',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.payments_outlined, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Text(
'$amount',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
color: theme.colorScheme.primary,
),
),
const Spacer(),
if (methodLabel.isNotEmpty)
Chip(
label: Text(methodLabel),
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 8),
Text(
'Bitte den Betrag bar entgegennehmen oder über das EC-Gerät '
'abrechnen und anschließend bestätigen.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
bottomNavigationBar: SafeArea(
top: false,
child: SizedBox(
width: double.infinity,
height: 90,
child: Center(
child: FilledButton(
onPressed: _isEmpty ? null : widget.onContinue,
child: Text(widget.buttonLabel),
),
),
),
),
);
}
}
// ─── Notizen-Block ────────────────────────────────────────────────────────
class _NotesSection extends StatelessWidget {
const _NotesSection({required this.notes});
final List<DeliveryNote> notes;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Anmerkungen zur Lieferung',
style:
theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
if (notes.isEmpty)
Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Keine Anmerkungen vorhanden.',
style: TextStyle(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
)
else
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (int i = 0; i < notes.length; i++) ...[
ListTile(
leading: Icon(
notes[i].imageAttachment != null
? (notes[i].imageAttachmentDeleted
? Icons.picture_as_pdf_outlined
: Icons.photo_outlined)
: Icons.event_note_outlined,
color: theme.colorScheme.primary,
),
title: Text(
notes[i].text?.trim().isNotEmpty == true
? notes[i].text!.trim()
: (notes[i].imageAttachmentDeleted
? 'Bild im Lieferbericht enthalten'
: 'Foto-Anhang'),
),
),
if (i < notes.length - 1)
const Divider(height: 1, indent: 16, endIndent: 16),
],
],
),
),
],
);
}
}
// ─── Bestätigungs-Checkbox ──────────────────────────────────────────────────
class _ConfirmTile extends StatelessWidget {
const _ConfirmTile({
required this.value,
required this.enabled,
required this.label,
required this.onChanged,
});
final bool value;
final bool enabled;
final String label;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return CheckboxListTile(
value: value,
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
title: Text(label, style: const TextStyle(fontSize: 14)),
);
}
}
// ─── Signatur-Pad ───────────────────────────────────────────────────────────
class _SignaturePad extends StatelessWidget {
const _SignaturePad({required this.controller, required this.onClear});
final SignatureController controller;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
height: 220,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.antiAlias,
child: Signature(
controller: controller,
backgroundColor: Colors.white,
),
),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: onClear,
icon: const Icon(Icons.undo),
label: const Text('Löschen'),
),
),
],
);
}
}