diff --git a/lib/data/repository/tour_repository_impl.dart b/lib/data/repository/tour_repository_impl.dart index e630f63..6380332 100644 --- a/lib/data/repository/tour_repository_impl.dart +++ b/lib/data/repository/tour_repository_impl.dart @@ -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( diff --git a/lib/domain/repository/tour_repository.dart b/lib/domain/repository/tour_repository.dart index 5d35561..cf4121f 100644 --- a/lib/domain/repository/tour_repository.dart +++ b/lib/domain/repository/tour_repository.dart @@ -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. diff --git a/lib/feature/delivery/bloc/tour_bloc.dart b/lib/feature/delivery/bloc/tour_bloc.dart index e1bfbfd..eab7ec1 100644 --- a/lib/feature/delivery/bloc/tour_bloc.dart +++ b/lib/feature/delivery/bloc/tour_bloc.dart @@ -1102,6 +1102,7 @@ class TourBloc extends Bloc { paymentMethodId: event.paymentMethodId, actorCarId: event.actorCarId, paymentCollected: event.paymentCollected, + internalNote: event.internalNote, ), ); } diff --git a/lib/feature/delivery/bloc/tour_event.dart b/lib/feature/delivery/bloc/tour_event.dart index 98fdaa6..195e103 100644 --- a/lib/feature/delivery/bloc/tour_event.dart +++ b/lib/feature/delivery/bloc/tour_event.dart @@ -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 diff --git a/lib/feature/delivery/detail/presentation/delivery_detail_page.dart b/lib/feature/delivery/detail/presentation/delivery_detail_page.dart index 1e4d188..f92dc53 100644 --- a/lib/feature/delivery/detail/presentation/delivery_detail_page.dart +++ b/lib/feature/delivery/detail/presentation/delivery_detail_page.dart @@ -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(); diff --git a/lib/feature/delivery/detail/presentation/delivery_sign.dart b/lib/feature/delivery/detail/presentation/delivery_sign.dart index c77757a..d414a3f 100644 --- a/lib/feature/delivery/detail/presentation/delivery_sign.dart +++ b/lib/feature/delivery/detail/presentation/delivery_sign.dart @@ -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 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 { 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 { notesAcknowledged: _notesEmpty ? false : _notesAccepted, acknowledgedNoteIds: _notesEmpty ? const [] : _notes.map((n) => n.id).toList(), + internalNote: decision.note, ), ); } diff --git a/lib/feature/delivery/detail/presentation/widget/internal_note_sheet.dart b/lib/feature/delivery/detail/presentation/widget/internal_note_sheet.dart new file mode 100644 index 0000000..b25aea0 --- /dev/null +++ b/lib/feature/delivery/detail/presentation/widget/internal_note_sheet.dart @@ -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 showInternalNoteSheet(BuildContext context) { + return showModalBottomSheet( + 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'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/openapi/holzleitner.json b/openapi/holzleitner.json index b53cfc7..3f96ac8 100644 --- a/openapi/holzleitner.json +++ b/openapi/holzleitner.json @@ -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)." diff --git a/packages/holzleitner_api/.openapi-generator/FILES b/packages/holzleitner_api/.openapi-generator/FILES index e07b1de..0930429 100644 --- a/packages/holzleitner_api/.openapi-generator/FILES +++ b/packages/holzleitner_api/.openapi-generator/FILES @@ -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 diff --git a/packages/holzleitner_api/doc/CompleteDeliveryAcknowledgements.md b/packages/holzleitner_api/doc/CompleteDeliveryAcknowledgements.md index 62c6def..7764f8d 100644 --- a/packages/holzleitner_api/doc/CompleteDeliveryAcknowledgements.md +++ b/packages/holzleitner_api/doc/CompleteDeliveryAcknowledgements.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **acknowledgedNoteIds** | **BuiltList<String>** | 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] diff --git a/packages/holzleitner_api/lib/src/model/complete_delivery_acknowledgements.dart b/packages/holzleitner_api/lib/src/model/complete_delivery_acknowledgements.dart index 769ac56..8807473 100644 --- a/packages/holzleitner_api/lib/src/model/complete_delivery_acknowledgements.dart +++ b/packages/holzleitner_api/lib/src/model/complete_delivery_acknowledgements.dart @@ -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 _$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, diff --git a/test/internal_note_sheet_test.dart b/test/internal_note_sheet_test.dart new file mode 100644 index 0000000..a6676cd --- /dev/null +++ b/test/internal_note_sheet_test.dart @@ -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 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); + }); +}