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>
This commit is contained in:
Dennis Nemec
2026-09-25 14:52:51 +02:00
parent dd7fe9a8eb
commit 6b934e4534
13 changed files with 268 additions and 11 deletions

View File

@ -203,6 +203,7 @@ class TourRepositoryImpl implements TourRepository {
String? paymentMethodId,
String? actorCarId,
bool paymentCollected = false,
String? internalNote,
}) async {
// multipart/form-data: zwei Signatur-PNGs + ein JSON-Feld mit den
// Bestätigungen. Direkt über die Dio-Instanz, weil der dart-dio-Generator
@ -215,6 +216,7 @@ class TourRepositoryImpl implements TourRepository {
'paymentCollected': paymentCollected,
if (paymentMethodId != null) 'paymentMethodId': paymentMethodId,
if (actorCarId != null) 'authorCarId': actorCarId,
if (internalNote != null) 'internalNote': internalNote,
};
final form = FormData.fromMap({
'customer_signature': MultipartFile.fromBytes(

View File

@ -101,6 +101,7 @@ abstract interface class TourRepository {
String? paymentMethodId,
String? actorCarId,
bool paymentCollected = false,
String? internalNote,
});
/// Legt eine neue Notiz an einer Lieferung an.

View File

@ -1102,6 +1102,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
paymentMethodId: event.paymentMethodId,
actorCarId: event.actorCarId,
paymentCollected: event.paymentCollected,
internalNote: event.internalNote,
),
);
}

View File

@ -225,6 +225,7 @@ class CompleteDelivery extends TourEvent {
this.paymentMethodId,
this.actorCarId,
this.paymentCollected = false,
this.internalNote,
});
final String deliveryId;
@ -242,6 +243,10 @@ class CompleteDelivery extends TourEvent {
/// Fahrer hat das Vor-Ort-Inkasso (Bar/EC) des offenen Betrags bestätigt.
/// `false`, wenn kein Inkasso anfiel (offen == 0 oder „Auf Rechnung").
final bool paymentCollected;
/// Optionale interne Notiz des Fahrers (nach den Unterschriften erfasst,
/// nur für den Lieferbericht). `null` = keine.
final String? internalNote;
}
/// Legt eine neue (Text- oder Bild-)Notiz an einer Lieferung an. Aktuell

View File

@ -422,6 +422,7 @@ class _BottomNav extends StatelessWidget {
acknowledgedNoteIds: result.acknowledgedNoteIds,
paymentMethodId: payment?.paymentMethodId,
paymentCollected: payment?.isCollectedOnSite ?? false,
internalNote: result.internalNote,
),
);
Navigator.of(routeContext).pop();

View File

@ -7,6 +7,7 @@ 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.
@ -17,6 +18,7 @@ class SignatureResult {
required this.receiptConfirmed,
required this.notesAcknowledged,
required this.acknowledgedNoteIds,
this.internalNote,
});
final Uint8List customerSignaturePng;
@ -24,6 +26,10 @@ class SignatureResult {
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.
@ -34,7 +40,8 @@ class SignatureResult {
/// 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.
/// 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
@ -133,6 +140,11 @@ class _SignatureViewState extends State<SignatureView> {
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,
@ -141,6 +153,7 @@ class _SignatureViewState extends State<SignatureView> {
notesAcknowledged: _notesEmpty ? false : _notesAccepted,
acknowledgedNoteIds:
_notesEmpty ? const [] : _notes.map((n) => n.id).toList(),
internalNote: decision.note,
),
);
}

View File

@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
/// Entscheidung im Blatt „Interne Notiz". `null` als Ergebnis von
/// [showInternalNoteSheet] heißt: zurück zur Unterschrift, nichts abschließen.
class InternalNoteDecision {
const InternalNoteDecision(this.note);
/// Getrimmter Text oder `null` = ohne Notiz abschließen.
final String? note;
}
/// Maximale Länge — deckungsgleich mit dem Backend (2000 Zeichen).
const int internalNoteMaxLength = 2000;
/// Fragt nach beiden Unterschriften optional eine interne Notiz ab.
///
/// Ohne Eingabe ist der große Button „Ohne Notiz abschließen" — der Fahrer
/// braucht also nur einen Tipp, wenn er nichts notieren will. Die Notiz ist
/// nur für interne Zwecke (Lieferbericht) und nicht Teil der Bestätigung
/// durch den Kunden.
Future<InternalNoteDecision?> showInternalNoteSheet(BuildContext context) {
return showModalBottomSheet<InternalNoteDecision>(
context: context,
isScrollControlled: true,
useSafeArea: true,
isDismissible: false,
enableDrag: false,
builder: (_) => const _InternalNoteSheet(),
);
}
class _InternalNoteSheet extends StatefulWidget {
const _InternalNoteSheet();
@override
State<_InternalNoteSheet> createState() => _InternalNoteSheetState();
}
class _InternalNoteSheetState extends State<_InternalNoteSheet> {
final TextEditingController _controller = TextEditingController();
@override
void initState() {
super.initState();
// Button-Label hängt vom Text ab („Ohne" vs. „Mit Notiz").
_controller.addListener(() => setState(() {}));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
String? get _note {
final text = _controller.text.trim();
return text.isEmpty ? null : text;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasNote = _note != null;
return Padding(
// Tastatur schiebt das Blatt hoch, statt die Buttons zu verdecken.
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
// Unten Abstand zur System-Navigationsleiste, sonst liegt
// „Zurück zur Unterschrift" darunter.
child: SafeArea(
top: false,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
Icons.sticky_note_2_outlined,
color: theme.colorScheme.primary,
),
const SizedBox(width: 10),
Text(
'Interne Notiz',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 6),
Text(
'Optional. Nur für interne Zwecke, der Kunde sieht sie nicht. '
'Die Notiz erscheint im Lieferbericht.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
TextField(
controller: _controller,
minLines: 3,
maxLines: 6,
maxLength: internalNoteMaxLength,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
hintText: 'z. B. Kunde wünscht Rückruf wegen Montage',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(56),
textStyle: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
onPressed:
() =>
Navigator.of(context).pop(InternalNoteDecision(_note)),
icon: const Icon(Icons.check),
label: Text(
hasNote ? 'Mit Notiz abschließen' : 'Ohne Notiz abschließen',
),
),
const SizedBox(height: 4),
TextButton(
style: TextButton.styleFrom(
minimumSize: const Size.fromHeight(44),
),
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zurück zur Unterschrift'),
),
],
),
),
),
);
}
}

View File

@ -2209,6 +2209,13 @@
"format": "uuid",
"description": "Fahrzeug des Akteurs (Audit-Spur). Muss zum Account geh\u00f6ren."
},
"internalNote": {
"type": [
"string",
"null"
],
"description": "Optionale interne Notiz des Fahrers, abgefragt nach beiden\nUnterschriften. Nur f\u00fcr interne Zwecke (Lieferbericht), nicht Teil\nder Kunden-Best\u00e4tigung. Leer/Whitespace = keine Notiz; max. 2000 Zeichen."
},
"notesAcknowledged": {
"type": "boolean",
"description": "\u201eAnmerkungen zur Lieferung zur Kenntnis genommen.\" \u2014 Pflicht nur, wenn\nNotizen existieren (das pr\u00fcft der Server)."

View File

@ -197,13 +197,3 @@ lib/src/model/update_service_request.dart
lib/src/model/warehouse.dart
lib/src/serializers.dart
pubspec.yaml
test/completed_deliveries_response_test.dart
test/completed_delivery_item_test.dart
test/delivery_details_test.dart
test/delivery_payment_response_test.dart
test/delivery_payment_test.dart
test/pending_review_response_test.dart
test/positions_modified_response_test.dart
test/record_delivery_payment_request_test.dart
test/resolve_review_request_test.dart
test/reviewed_item_response_test.dart

View File

@ -10,6 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**acknowledgedNoteIds** | **BuiltList&lt;String&gt;** | Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-bestätigt wurden (Audit-Robustheit). | [optional]
**authorCarId** | **String** | Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. | [optional]
**internalNote** | **String** | Optionale interne Notiz des Fahrers, abgefragt nach beiden Unterschriften. Nur für interne Zwecke (Lieferbericht), nicht Teil der Kunden-Bestätigung. Leer/Whitespace = keine Notiz; max. 2000 Zeichen. | [optional]
**notesAcknowledged** | **bool** | „Anmerkungen zur Lieferung zur Kenntnis genommen.\" — Pflicht nur, wenn Notizen existieren (das prüft der Server). | [optional]
**paymentCollected** | **bool** | Inkasso-Bestätigung des Fahrers: „der offene Betrag wurde erhalten (bar) bzw. über das EC-Gerät abgerechnet.\" Pflicht nur, wenn beim Abschluss ein offener Betrag > 0 besteht UND die Methode ein Vor-Ort- Inkasso ist (Bar/EC) — das prüft der Server. Der kassierte Betrag wird server-seitig autoritativ berechnet (nicht vom Client übernommen). | [optional]
**paymentMethodId** | **String** | Optionale Zahlungsmethode, die der Fahrer beim Abschluss gewählt hat. `None` = die am Beleg hinterlegte Methode bleibt. Falls gesetzt, muss sie existieren **und** aktiv sein (vom Server geprüft). | [optional]

View File

@ -14,6 +14,7 @@ part 'complete_delivery_acknowledgements.g.dart';
/// Properties:
/// * [acknowledgedNoteIds] - Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-bestätigt wurden (Audit-Robustheit).
/// * [authorCarId] - Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören.
/// * [internalNote] - Optionale interne Notiz des Fahrers, abgefragt nach beiden Unterschriften. Nur für interne Zwecke (Lieferbericht), nicht Teil der Kunden-Bestätigung. Leer/Whitespace = keine Notiz; max. 2000 Zeichen.
/// * [notesAcknowledged] - „Anmerkungen zur Lieferung zur Kenntnis genommen.\" — Pflicht nur, wenn Notizen existieren (das prüft der Server).
/// * [paymentCollected] - Inkasso-Bestätigung des Fahrers: „der offene Betrag wurde erhalten (bar) bzw. über das EC-Gerät abgerechnet.\" Pflicht nur, wenn beim Abschluss ein offener Betrag > 0 besteht UND die Methode ein Vor-Ort- Inkasso ist (Bar/EC) — das prüft der Server. Der kassierte Betrag wird server-seitig autoritativ berechnet (nicht vom Client übernommen).
/// * [paymentMethodId] - Optionale Zahlungsmethode, die der Fahrer beim Abschluss gewählt hat. `None` = die am Beleg hinterlegte Methode bleibt. Falls gesetzt, muss sie existieren **und** aktiv sein (vom Server geprüft).
@ -28,6 +29,10 @@ abstract class CompleteDeliveryAcknowledgements implements Built<CompleteDeliver
@BuiltValueField(wireName: r'authorCarId')
String? get authorCarId;
/// Optionale interne Notiz des Fahrers, abgefragt nach beiden Unterschriften. Nur für interne Zwecke (Lieferbericht), nicht Teil der Kunden-Bestätigung. Leer/Whitespace = keine Notiz; max. 2000 Zeichen.
@BuiltValueField(wireName: r'internalNote')
String? get internalNote;
/// „Anmerkungen zur Lieferung zur Kenntnis genommen.\" — Pflicht nur, wenn Notizen existieren (das prüft der Server).
@BuiltValueField(wireName: r'notesAcknowledged')
bool? get notesAcknowledged;
@ -81,6 +86,13 @@ class _$CompleteDeliveryAcknowledgementsSerializer implements PrimitiveSerialize
specifiedType: const FullType.nullable(String),
);
}
if (object.internalNote != null) {
yield r'internalNote';
yield serializers.serialize(
object.internalNote,
specifiedType: const FullType.nullable(String),
);
}
if (object.notesAcknowledged != null) {
yield r'notesAcknowledged';
yield serializers.serialize(
@ -145,6 +157,14 @@ class _$CompleteDeliveryAcknowledgementsSerializer implements PrimitiveSerialize
if (valueDes == null) continue;
result.authorCarId = valueDes;
break;
case r'internalNote':
final valueDes = serializers.deserialize(
value,
specifiedType: const FullType.nullable(String),
) as String?;
if (valueDes == null) continue;
result.internalNote = valueDes;
break;
case r'notesAcknowledged':
final valueDes = serializers.deserialize(
value,

View File

@ -13,6 +13,8 @@ class _$CompleteDeliveryAcknowledgements
@override
final String? authorCarId;
@override
final String? internalNote;
@override
final bool? notesAcknowledged;
@override
final bool? paymentCollected;
@ -28,6 +30,7 @@ class _$CompleteDeliveryAcknowledgements
_$CompleteDeliveryAcknowledgements._(
{this.acknowledgedNoteIds,
this.authorCarId,
this.internalNote,
this.notesAcknowledged,
this.paymentCollected,
this.paymentMethodId,
@ -48,6 +51,7 @@ class _$CompleteDeliveryAcknowledgements
return other is CompleteDeliveryAcknowledgements &&
acknowledgedNoteIds == other.acknowledgedNoteIds &&
authorCarId == other.authorCarId &&
internalNote == other.internalNote &&
notesAcknowledged == other.notesAcknowledged &&
paymentCollected == other.paymentCollected &&
paymentMethodId == other.paymentMethodId &&
@ -59,6 +63,7 @@ class _$CompleteDeliveryAcknowledgements
var _$hash = 0;
_$hash = $jc(_$hash, acknowledgedNoteIds.hashCode);
_$hash = $jc(_$hash, authorCarId.hashCode);
_$hash = $jc(_$hash, internalNote.hashCode);
_$hash = $jc(_$hash, notesAcknowledged.hashCode);
_$hash = $jc(_$hash, paymentCollected.hashCode);
_$hash = $jc(_$hash, paymentMethodId.hashCode);
@ -72,6 +77,7 @@ class _$CompleteDeliveryAcknowledgements
return (newBuiltValueToStringHelper(r'CompleteDeliveryAcknowledgements')
..add('acknowledgedNoteIds', acknowledgedNoteIds)
..add('authorCarId', authorCarId)
..add('internalNote', internalNote)
..add('notesAcknowledged', notesAcknowledged)
..add('paymentCollected', paymentCollected)
..add('paymentMethodId', paymentMethodId)
@ -96,6 +102,10 @@ class CompleteDeliveryAcknowledgementsBuilder
String? get authorCarId => _$this._authorCarId;
set authorCarId(String? authorCarId) => _$this._authorCarId = authorCarId;
String? _internalNote;
String? get internalNote => _$this._internalNote;
set internalNote(String? internalNote) => _$this._internalNote = internalNote;
bool? _notesAcknowledged;
bool? get notesAcknowledged => _$this._notesAcknowledged;
set notesAcknowledged(bool? notesAcknowledged) =>
@ -125,6 +135,7 @@ class CompleteDeliveryAcknowledgementsBuilder
if ($v != null) {
_acknowledgedNoteIds = $v.acknowledgedNoteIds?.toBuilder();
_authorCarId = $v.authorCarId;
_internalNote = $v.internalNote;
_notesAcknowledged = $v.notesAcknowledged;
_paymentCollected = $v.paymentCollected;
_paymentMethodId = $v.paymentMethodId;
@ -154,6 +165,7 @@ class CompleteDeliveryAcknowledgementsBuilder
_$CompleteDeliveryAcknowledgements._(
acknowledgedNoteIds: _acknowledgedNoteIds?.build(),
authorCarId: authorCarId,
internalNote: internalNote,
notesAcknowledged: notesAcknowledged,
paymentCollected: paymentCollected,
paymentMethodId: paymentMethodId,

View File

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/internal_note_sheet.dart';
void main() {
late InternalNoteDecision? result;
late bool closed;
Future<void> open(WidgetTester tester) async {
result = null;
closed = false;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () async {
result = await showInternalNoteSheet(context);
closed = true;
},
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
testWidgets('ohne Eingabe: ein Tipp schließt ohne Notiz ab', (tester) async {
await open(tester);
await tester.tap(find.text('Ohne Notiz abschließen'));
await tester.pumpAndSettle();
expect(closed, isTrue);
expect(result, isNotNull);
expect(result!.note, isNull);
});
testWidgets('mit Eingabe: Button wechselt, Text wird getrimmt',
(tester) async {
await open(tester);
await tester.enterText(find.byType(TextField), ' Rückruf wegen Montage ');
await tester.pump();
expect(find.text('Mit Notiz abschließen'), findsOneWidget);
await tester.tap(find.text('Mit Notiz abschließen'));
await tester.pumpAndSettle();
expect(result!.note, 'Rückruf wegen Montage');
});
testWidgets('Zurück bricht das Abschließen ab', (tester) async {
await open(tester);
await tester.tap(find.text('Zurück zur Unterschrift'));
await tester.pumpAndSettle();
expect(closed, isTrue);
expect(result, isNull);
});
}