- Neuer Workflow-Step "Zahlung" zeigt ausgelieferte Artikel mit Menge und Preis, Betragsaufstellung, Zahlungsstatus und einen grossen Button "Zahlung abwickeln" (fest unten) - Zahlungs-Modal: grosser offener Betrag, Methoden als Cards aus den Backend-Stammdaten, bei EC-Karte gross die Kundennummer; schliesst nur ueber Abbrechen, "Zahlung erhalten" oder "Lieferung abbrechen" (mit Grund) - Bestaetigung wird am Server protokolliert (RecordDeliveryPayment); bis dahin sind "Weiter" und die Uebersicht gesperrt, ausser es ist nichts offen (bereits bezahlt). Aendert sich der Betrag danach, wird die Zahlung als veraltet markiert und muss erneut abgewickelt werden - Uebersicht zeigt die abgewickelte Zahlung statt eigener Methodenauswahl; Unterschrift-Flow ohne doppelte Inkasso-Stufe; Abschluss sendet Methode und Inkasso-Flag der gueltigen Zahlung - DeliveryPaymentStatus als einzige Quelle fuer offenen Betrag (gleiche Formel wie Backend); Artikelliste/Betragskarte als geteilte Widgets; Begruendungs-Dialog geteilt mit Info-Step - API-Client aus aktueller Backend-Spec neu generiert - Tests fuer Zahlungsstatus und Modal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
353 lines
11 KiB
Dart
353 lines
11 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,
|
|
});
|
|
|
|
final Uint8List customerSignaturePng;
|
|
final Uint8List driverSignaturePng;
|
|
final bool receiptConfirmed;
|
|
final bool notesAcknowledged;
|
|
final List<String> acknowledgedNoteIds;
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// 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;
|
|
widget.onSigned(
|
|
SignatureResult(
|
|
customerSignaturePng: customerPng,
|
|
driverSignaturePng: driverPng,
|
|
receiptConfirmed: _receiptAccepted,
|
|
notesAcknowledged: _notesEmpty ? false : _notesAccepted,
|
|
acknowledgedNoteIds:
|
|
_notesEmpty ? const [] : _notes.map((n) => n.id).toList(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
@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'),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|