Files
Holzleitner-Lieferservice-App/lib/feature/delivery/detail/presentation/steps/step_notes.dart
Dennis Nemec a9bf8ecdd1 Final commit.
2026-06-01 17:12:28 +02:00

717 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.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/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/widget/attachment_image.dart';
/// Step 2 — Notizen & Fotos.
///
/// Die UI trennt bewusst in **zwei Sektionen**, weil es zwei
/// unterschiedliche Dinge sind:
/// * **Notizen** (Text): anlegen / bearbeiten / löschen über den
/// `TourBloc` (Backend-Endpoints vorhanden).
/// * **Fotos** (Bild-Notizen): `image_picker` → Upload über
/// `TourBloc.UploadDeliveryNoteImage`. Das Backend schiebt das Bild nach
/// DOCUframe und legt eine Notiz mit der Referenz an. Fotos werden als
/// Thumbnail angezeigt (Tap → formatfüllend) und können nur gelöscht,
/// nicht inline bearbeitet werden.
///
/// Datenmodell: beides sind `DeliveryNote`s. Unterschieden wird über
/// `imageAttachment != null` (Foto) bzw. `text != null` (Notiz).
class StepNotes extends StatelessWidget {
const StepNotes({super.key, required this.delivery, required this.details});
final Delivery delivery;
final TourDetails details;
void _openAddNoteDialog(BuildContext context) {
final tourBloc = context.read<TourBloc>();
showDialog<void>(
context: context,
builder: (_) => _NoteEditorDialog(
title: 'Notiz hinzufügen',
onSubmit: (text) => tourBloc.add(
AddDeliveryNote(deliveryId: delivery.id, text: text),
),
),
);
}
Future<void> _pickImage(BuildContext context) async {
// Bloc vor dem await greifen — danach kein context-Zugriff über den
// async-Gap.
final tourBloc = context.read<TourBloc>();
final picker = ImagePicker();
// Bild schon on-device runterskalieren + JPEG-komprimieren: Foto-Notizen
// brauchen keine 12-MP-Originale. Spart Upload-/Speicher-/Report-Größe
// (ein 4080×3060-Foto ~2,9 MB → ~200400 KB). 1600 px / Q82 deckt sich mit
// dem Backend-Report-Renderer.
final file = await picker.pickImage(
source: ImageSource.camera,
maxWidth: 1600,
maxHeight: 1600,
imageQuality: 82,
);
if (file == null) return;
final bytes = await file.readAsBytes();
tourBloc.add(
UploadDeliveryNoteImage(
deliveryId: delivery.id,
filename: file.name,
mime: file.mimeType ?? _mimeFromName(file.name),
bytes: bytes,
),
);
}
/// Grober MIME-Fallback, wenn der Picker keinen Typ liefert (Kamera gibt
/// meist JPEG). Reicht für das `Content-Type` des Multipart-Felds.
String _mimeFromName(String name) {
final lower = name.toLowerCase();
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.heic')) return 'image/heic';
if (lower.endsWith('.webp')) return 'image/webp';
return 'image/jpeg';
}
@override
Widget build(BuildContext context) {
final notes = details.notesOf(delivery.id);
// Notizen & Fotos sind nur bei aktiver Lieferung änderbar. Ist die
// Lieferung beendet (abgeschlossen/abgebrochen/pausiert), bleiben sie
// sichtbar, aber read-only: kein FAB, keine Aktions-Menüs, kein Löschen.
final active = delivery.state == DeliveryState.active;
// Sauber in Text-Notizen und Fotos aufteilen — getrennte Sektionen.
final textNotes =
notes.where((n) => n.imageAttachment == null).toList(growable: false);
final photoNotes =
notes.where((n) => n.imageAttachment != null).toList(growable: false);
return Stack(
children: [
ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
children: [
if (!active) ...[
const _ReadOnlyBanner(),
const SizedBox(height: 16),
],
_SectionHeader(text: 'Notizen (${textNotes.length})'),
const SizedBox(height: 8),
if (textNotes.isEmpty)
const _EmptyHint(
icon: Icons.notes,
text: 'Noch keine Notizen erfasst.',
)
else
for (final n in textNotes)
_NoteCard(note: n, deliveryId: delivery.id, active: active),
const SizedBox(height: 24),
_SectionHeader(text: 'Fotos (${photoNotes.length})'),
const SizedBox(height: 8),
if (photoNotes.isEmpty)
const _EmptyHint(
icon: Icons.photo_camera_outlined,
text: 'Noch keine Fotos aufgenommen.',
)
else
for (final n in photoNotes)
_PhotoCard(note: n, deliveryId: delivery.id, active: active),
],
),
// FAB nur bei aktiver Lieferung — sonst ist Hinzufügen gesperrt.
if (active)
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.all(16),
child: _AddMenu(
onAddNote: () => _openAddNoteDialog(context),
onAddImage: () => _pickImage(context),
),
),
),
],
);
}
}
/// Hinweis-Balken oben in der Notiz-Sektion, wenn die Lieferung nicht mehr
/// aktiv ist — Notizen/Fotos sind dann reine Anzeige.
class _ReadOnlyBanner extends StatelessWidget {
const _ReadOnlyBanner();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.lock_outline,
size: 18, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
'Lieferung beendet — Notizen & Fotos können nicht mehr '
'hinzugefügt, geändert oder gelöscht werden.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
);
}
}
class _EmptyHint extends StatelessWidget {
const _EmptyHint({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 12),
Text(
text,
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
),
),
);
}
}
enum _NoteAction { edit, delete }
/// Geteiltes Zeitformat für Notiz- und Foto-Karten.
String _formatNoteTime(DateTime t) =>
'${t.day.toString().padLeft(2, "0")}.${t.month.toString().padLeft(2, "0")}.${t.year} '
'${t.hour.toString().padLeft(2, "0")}:${t.minute.toString().padLeft(2, "0")}';
/// Geteilter Lösch-Bestätigungsdialog. Wording variiert je nachdem, ob eine
/// Text-Notiz oder ein Foto entfernt wird; gefeuert wird in beiden Fällen
/// dasselbe `DeleteDeliveryNote`-Event (Foto ist intern auch eine Notiz).
Future<void> _confirmDeleteNote(
BuildContext context, {
required String deliveryId,
required String noteId,
required bool isPhoto,
}) async {
final tourBloc = context.read<TourBloc>();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(isPhoto ? 'Foto löschen?' : 'Notiz löschen?'),
content: Text(
isPhoto
? 'Das Foto wird dauerhaft entfernt.'
: 'Die Notiz wird dauerhaft entfernt.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Abbrechen'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
foregroundColor: Theme.of(ctx).colorScheme.onError,
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Löschen'),
),
],
),
);
if (confirmed != true) return;
tourBloc.add(DeleteDeliveryNote(deliveryId: deliveryId, noteId: noteId));
}
/// Karte einer **Text-Notiz**. Normale Notizen sind bearbeitbar und löschbar.
/// **System-verwaltete** Grund-Notizen (Mengen-Gutschrift via
/// `creditDeliveryItemId` oder Betrags-Gutschrift via `isAmountCreditNote`)
/// dürfen vom Fahrer nicht manuell geändert/gelöscht werden — sie werden
/// automatisch mit der jeweiligen Gutschrift angelegt und wieder entfernt.
class _NoteCard extends StatelessWidget {
const _NoteCard({
required this.note,
required this.deliveryId,
required this.active,
});
final DeliveryNote note;
final String deliveryId;
/// Nur bei aktiver Lieferung darf bearbeitet/gelöscht werden.
final bool active;
void _openEditDialog(BuildContext context) {
final tourBloc = context.read<TourBloc>();
showDialog<void>(
context: context,
builder: (_) => _NoteEditorDialog(
title: 'Notiz bearbeiten',
initialText: note.text,
onSubmit: (text) => tourBloc.add(
UpdateDeliveryNote(
deliveryId: deliveryId,
noteId: note.id,
text: text,
),
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// System-verwaltete Grund-Notiz: kein manuelles Bearbeiten/Löschen.
final isSystemManaged =
note.creditDeliveryItemId != null || note.isAmountCreditNote;
// Aktions-Menü nur bei aktiver Lieferung UND nicht-System-Notiz.
final canEdit = active && !isSystemManaged;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
note.text ?? '',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 6),
Text(
'Personalnr. ${note.authorPersonalnummer} '
'· ${_formatNoteTime(note.createdAt)}',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
if (!canEdit)
Padding(
padding: const EdgeInsets.only(right: 8, top: 6),
child: Tooltip(
message: isSystemManaged
? 'Automatisch verwaltet wird mit der Gutschrift '
'angelegt und beim Zurücknehmen wieder entfernt.'
: 'Lieferung beendet Notiz nicht mehr änderbar.',
child: Icon(
Icons.lock_outline,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else
PopupMenuButton<_NoteAction>(
icon: const Icon(Icons.more_vert),
tooltip: 'Notiz-Aktionen',
onSelected: (action) {
switch (action) {
case _NoteAction.edit:
_openEditDialog(context);
case _NoteAction.delete:
_confirmDeleteNote(
context,
deliveryId: deliveryId,
noteId: note.id,
isPhoto: false,
);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: _NoteAction.edit,
child: Row(
children: [
Icon(Icons.edit_outlined),
SizedBox(width: 12),
Text('Bearbeiten'),
],
),
),
PopupMenuItem(
value: _NoteAction.delete,
child: Row(
children: [
Icon(Icons.delete_outline,
color: theme.colorScheme.error),
const SizedBox(width: 12),
Text(
'Löschen',
style: TextStyle(color: theme.colorScheme.error),
),
],
),
),
],
),
],
),
),
);
}
}
/// Karte eines **Fotos** — Thumbnail (Tap → formatfüllend) plus Metazeile
/// mit Lösch-Button. Kein Inline-Edit (ein Foto bearbeitet man nicht).
class _PhotoCard extends StatelessWidget {
const _PhotoCard({
required this.note,
required this.deliveryId,
required this.active,
});
final DeliveryNote note;
final String deliveryId;
/// Nur bei aktiver Lieferung darf das Foto gelöscht werden.
final bool active;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.only(bottom: 8),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_NoteImageThumb(
attachmentId: note.imageAttachment!,
deleted: note.imageAttachmentDeleted,
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 8),
child: Row(
children: [
Icon(
Icons.photo_camera_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'Personalnr. ${note.authorPersonalnummer} '
'· ${_formatNoteTime(note.createdAt)}',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (active)
IconButton(
icon: Icon(
Icons.delete_outline,
color: theme.colorScheme.error,
),
tooltip: 'Foto löschen',
onPressed: () => _confirmDeleteNote(
context,
deliveryId: deliveryId,
noteId: note.id,
isPhoto: true,
),
)
else
Padding(
padding: const EdgeInsets.only(right: 8),
child: Tooltip(
message: 'Lieferung beendet Foto nicht mehr löschbar.',
child: Icon(
Icons.lock_outline,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
),
);
}
}
/// Thumbnail einer Bild-Notiz; Tap öffnet das Bild formatfüllend mit
/// Zoom/Pan.
class _NoteImageThumb extends StatelessWidget {
const _NoteImageThumb({required this.attachmentId, this.deleted = false});
final String attachmentId;
/// Lokale Bilddatei nach Report-Upload gelöscht → Hinweis statt Vorschau.
final bool deleted;
void _openFull(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
),
body: Center(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 5,
child: AttachmentImage(
attachmentId: attachmentId,
width: 2048,
height: 2048,
quality: 90,
fit: BoxFit.contain,
deleted: deleted,
),
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
// Kein eigenes Clipping — die umgebende `_PhotoCard` clippt bereits
// (Clip.antiAlias), sonst gäbe es doppelt gerundete Ecken.
return GestureDetector(
// Gelöschtes Bild → kein Vollbild öffnen (es gibt nichts zu laden).
onTap: deleted ? null : () => _openFull(context),
child: SizedBox(
height: 160,
width: double.infinity,
child: AttachmentImage(
attachmentId: attachmentId,
width: 600,
height: 600,
deleted: deleted,
),
),
);
}
}
// ─── Add-Menu (FAB) ─────────────────────────────────────────────────────
enum _AddAction { note, image }
class _AddMenu extends StatelessWidget {
const _AddMenu({required this.onAddNote, required this.onAddImage});
final VoidCallback onAddNote;
final VoidCallback onAddImage;
@override
Widget build(BuildContext context) {
return PopupMenuButton<_AddAction>(
tooltip: 'Hinzufügen',
onSelected: (action) {
switch (action) {
case _AddAction.note:
onAddNote();
case _AddAction.image:
onAddImage();
}
},
itemBuilder: (context) => const [
PopupMenuItem(
value: _AddAction.note,
child: Row(
children: [
Icon(Icons.edit_note),
SizedBox(width: 12),
Text('Notiz schreiben'),
],
),
),
PopupMenuItem(
value: _AddAction.image,
child: Row(
children: [
Icon(Icons.camera_alt_outlined),
SizedBox(width: 12),
Text('Foto aufnehmen'),
],
),
),
],
child: FloatingActionButton.extended(
onPressed: null, // PopupMenuButton fängt den Tap
icon: const Icon(Icons.add),
label: const Text('Hinzufügen'),
),
);
}
}
// ─── Notiz-Dialog (Text) ────────────────────────────────────────────────
/// Editor-Dialog für Text-Notizen — geteilt zwischen „Hinzufügen" und
/// „Bearbeiten". Liefert den getrimmten Text per [onSubmit]; der Aufrufer
/// entscheidet, ob daraus ein `AddDeliveryNote` oder `UpdateDeliveryNote`
/// wird.
class _NoteEditorDialog extends StatefulWidget {
const _NoteEditorDialog({
required this.title,
required this.onSubmit,
this.initialText,
});
final String title;
final void Function(String text) onSubmit;
final String? initialText;
@override
State<_NoteEditorDialog> createState() => _NoteEditorDialogState();
}
class _NoteEditorDialogState extends State<_NoteEditorDialog> {
late final TextEditingController _controller =
TextEditingController(text: widget.initialText ?? '');
bool _empty = true;
@override
void initState() {
super.initState();
_empty = _controller.text.trim().isEmpty;
_controller.addListener(() {
setState(() => _empty = _controller.text.trim().isEmpty);
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _save() {
final text = _controller.text.trim();
if (text.isEmpty) return;
widget.onSubmit(text);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.55,
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
widget.title,
style: Theme.of(context).textTheme.titleLarge,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 12),
// TODO(B6): Templates-Dropdown ergänzen, sobald Backend
// Notiz-Templates als Stammdaten anbietet.
Expanded(
child: TextField(
controller: _controller,
autofocus: true,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
decoration: const InputDecoration(
labelText: 'Notiz',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Abbrechen'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _empty ? null : _save,
icon: const Icon(Icons.save),
label: const Text('Speichern'),
),
],
),
],
),
),
),
);
}
}