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

@ -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'),
),
],
),
),
),
);
}
}