466 lines
16 KiB
Dart
466 lines
16 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:signature/signature.dart';
|
|
|
|
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.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;
|
|
|
|
/// 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<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: 3,
|
|
penColor: Colors.black,
|
|
exportBackgroundColor: Colors.white,
|
|
);
|
|
final SignatureController _driverController = SignatureController(
|
|
penStrokeWidth: 3,
|
|
penColor: Colors.black,
|
|
exportBackgroundColor: Colors.white,
|
|
);
|
|
|
|
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();
|
|
// 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
|
|
void dispose() {
|
|
_customerController.dispose();
|
|
_driverController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
bool get _customerStepValid =>
|
|
_receiptAccepted && (_notesAccepted || _notesEmpty) && !_customerEmpty;
|
|
|
|
/// 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;
|
|
}
|
|
}
|
|
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 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(title)),
|
|
body: SafeArea(
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── 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'),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|