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(); showDialog( context: context, builder: (_) => _NoteEditorDialog( title: 'Notiz hinzufügen', onSubmit: (text) => tourBloc.add( AddDeliveryNote(deliveryId: delivery.id, text: text), ), ), ); } Future _pickImage(BuildContext context) async { // Bloc vor dem await greifen — danach kein context-Zugriff über den // async-Gap. final tourBloc = context.read(); 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 → ~200–400 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 _confirmDeleteNote( BuildContext context, { required String deliveryId, required String noteId, required bool isPhoto, }) async { final tourBloc = context.read(); final confirmed = await showDialog( 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(); showDialog( 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'), ), ], ), ], ), ), ), ); } }