Files
Holzleitner-Lieferservice-App/lib/feature/delivery/detail/presentation/delivery_sign.dart
Dennis Nemec 857c92031a feat: abweichende Lieferadresse und Empfänger vor Ort anzeigen
Belege können im ERP eine eigene Lieferadresse (LieferAdressId) mit
anderer Person tragen als der Besteller. Die App zeigt jetzt überall den
Empfänger vor Ort:

- TourDetails.recipientOf liefert Empfänger (Name aus der Lieferadresse,
  sonst Besteller), Lieferanschrift und differsFromOrderer.
- Sortieren, Auswählen, Beladen und Liefer-Übersicht: Empfängername +
  Lieferadresse, bei Abweichung „Bestellt von: <Kunde>".
- Lieferdetails: hervorgehobene Karte „Abweichende Lieferadresse" mit
  Empfänger, Anschrift und Kontakt; Kunden-Block als „Besteller (Kunde)"
  mit Adresse, Kundennummer und Kontaktdaten.
- Maps navigiert zur Lieferadresse statt zur Kundenadresse; AppBar,
  Unterschrift und Beladen-Avatar nutzen den Empfänger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:51:31 +02:00

368 lines
12 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';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/internal_note_sheet.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,
this.internalNote,
});
final Uint8List customerSignaturePng;
final Uint8List driverSignaturePng;
final bool receiptConfirmed;
final bool notesAcknowledged;
final List<String> acknowledgedNoteIds;
/// Optionale interne Notiz des Fahrers, abgefragt nach beiden
/// Unterschriften. `null` = keine.
final String? internalNote;
}
/// Mehrstufiger Unterschrift-Flow zum Abschließen einer Lieferung.
///
/// Das Kassieren passiert vorher im eigenen Workflow-Step „Zahlung" und
/// wird dort protokolliert — dieser Flow enthält nur noch die 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. Danach fragt ein Blatt optional eine
/// interne Notiz ab (nur für den Lieferbericht, nicht für den Kunden).
///
/// 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,
});
final Delivery delivery;
final TourDetails details;
final void Function(SignatureResult result) onSigned;
@override
State<SignatureView> createState() => _SignatureViewState();
}
/// Stufen des Abschluss-Flows.
enum _SignStage { 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;
_SignStage _stage = _SignStage.customer;
bool _receiptAccepted = false;
bool _notesAccepted = false;
bool _customerEmpty = true;
bool _driverEmpty = true;
bool get _notesEmpty => _notes.isEmpty;
@override
void initState() {
super.initState();
_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.customer:
return _customerStepValid;
case _SignStage.driver:
return !_driverEmpty;
}
}
Future<void> _onPrimaryPressed() async {
switch (_stage) {
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;
if (!mounted) return;
// Nach beiden Unterschriften: optional interne Notiz. „Zurück" im
// Blatt bricht nur das Abschließen ab, die Unterschriften bleiben.
final decision = await showInternalNoteSheet(context);
if (decision == null || !mounted) return;
widget.onSigned(
SignatureResult(
customerSignaturePng: customerPng,
driverSignaturePng: driverPng,
receiptConfirmed: _receiptAccepted,
notesAcknowledged: _notesEmpty ? false : _notesAccepted,
acknowledgedNoteIds:
_notesEmpty ? const [] : _notes.map((n) => n.id).toList(),
internalNote: decision.note,
),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Unterschrieben wird vor Ort — Name und Ort also vom Empfänger, der
// vom Besteller abweichen kann.
final recipient = widget.details.recipientOf(widget.delivery);
final date = DateFormat('dd.MM.yyyy').format(DateTime.now());
final isDriver = _stage == _SignStage.driver;
final String title;
switch (_stage) {
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 (!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: ${recipient.name}',
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(
'${recipient.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'),
),
],
),
),
);
}
}
// ─── 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'),
),
),
],
);
}
}