Files
Holzleitner-Lieferservice-App/lib/feature/delivery/detail/presentation/delivery_sign.dart
Dennis Nemec 6b934e4534 feat(abschluss): interne Notiz nach den Unterschriften abfragen
- Nach "Abschliessen" oeffnet sich ein Blatt "Interne Notiz" (optional,
  max. 2000 Zeichen). Ohne Eingabe genuegt ein Tipp auf "Ohne Notiz
  abschliessen"; "Zurueck zur Unterschrift" bricht nur das Abschliessen ab
- Die Notiz reist im Abschluss-Aufruf mit (internalNote) und landet im
  Lieferbericht, nicht in den Kunden-Notizen
- API-Client aus aktueller Backend-Spec neu generiert
- Widget-Tests fuer das Blatt

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

366 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);
final customer = widget.details.customerOf(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: ${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'),
),
],
),
),
);
}
}
// ─── 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'),
),
),
],
);
}
}