Final commit.

This commit is contained in:
Dennis Nemec
2026-06-01 17:12:28 +02:00
parent 3ecbc82885
commit a9bf8ecdd1
385 changed files with 29081 additions and 12089 deletions

View File

@ -0,0 +1,716 @@
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'),
),
],
),
],
),
),
),
);
}
}