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

@ -1,32 +0,0 @@
import 'package:flutter/cupertino.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_article_management.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_delivery_options.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_info.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_note.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_summary.dart';
import 'package:hl_lieferservice/model/delivery.dart';
abstract class IStepFactory {
Widget? make(int step, Delivery delivery);
}
class StepFactory extends IStepFactory {
@override
Widget? make(int step, Delivery delivery) {
switch(step) {
case 0:
return DeliveryStepInfo(delivery: delivery);
case 1:
return DeliveryStepNote(delivery: delivery);
case 2:
return DeliveryStepArticleManagement(delivery: delivery);
case 3:
return DeliveryStepOptions(delivery: delivery);
case 4:
return DeliveryStepSummary(delivery: delivery);
}
return null;
}
}

View File

@ -1,74 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/article/article_list.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_discount.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/model/delivery.dart';
class DeliveryStepArticleManagement extends StatefulWidget {
final Delivery delivery;
const DeliveryStepArticleManagement({required this.delivery, super.key});
@override
State<StatefulWidget> createState() => _DeliveryStepInfo();
}
class _DeliveryStepInfo extends State<DeliveryStepArticleManagement> {
Widget _articleOverview() {
TourLoaded tour = context.read<TourBloc>().state as TourLoaded;
return ArticleList(
articles:
widget.delivery.articles
.where(
(article) =>
article.articleNumber != tour.tour.discountArticleNumber,
)
.toList(),
deliveryId: widget.delivery.id,
);
}
Widget _discountView() {
return DeliveryDiscount(
disabled: false,
discount: widget.delivery.discount,
deliveryId: widget.delivery.id,
);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(10),
child: Container(
width: double.infinity,
alignment: Alignment.centerLeft,
child: ListView(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
"Artikel",
style: Theme.of(context).textTheme.headlineMedium,
),
),
_articleOverview(),
Padding(
padding: const EdgeInsets.only(top: 20, bottom: 10),
child: Text(
"Gutschriften",
style: Theme.of(context).textTheme.headlineMedium,
),
),
_discountView(),
],
),
),
);
}
}

View File

@ -0,0 +1,395 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/scan_progress.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.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/feature/delivery/detail/presentation/widget/discount_editor.dart';
import 'package:hl_lieferservice/feature/loading/widget/reason_catalog.dart';
import 'package:hl_lieferservice/feature/loading/widget/reason_picker_sheet.dart';
/// Step 3 — Artikel & Gutschriften.
///
/// * **Mengen-Gutschrift** (Belegzeile ganz ODER teilweise entfernen) ist
/// voll funktional über `RemoveItem`/`UnremoveItem` (mit `quantity`) →
/// Backend Audit-Action `remove`/`unremove`. Die `creditedQuantity` des
/// Items ist die Wahrheit (vom Server), kein lokaler Draft mehr.
/// Regeln (serverseitig erzwungen): scannbare Position muss `done` sein,
/// Lieferung muss `active` sein. Die UI spiegelt das als Gate + Hinweis.
/// * **Betrags-Gutschrift** (≤ 150 € in 10er-Schritten + Grund) ist
/// backend-gestützt über `SetDeliveryCredit`/`RemoveDeliveryCredit` am
/// `TourBloc` → `POST /deliveries/{id}/credit`. Wahrheit ist der Server
/// (`TourDetails.creditOf`), kein lokaler Draft mehr.
class StepArticles extends StatelessWidget {
const StepArticles({
super.key,
required this.delivery,
required this.details,
});
final Delivery delivery;
final TourDetails details;
@override
Widget build(BuildContext context) {
// Innerhalb einer Belegzeile: Oberartikel vor seinen Komponenten, damit
// Komponenten direkt darunter eingerückt erscheinen.
final items = List<DeliveryItem>.of(delivery.items)
..sort((a, b) {
final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr);
if (byLine != 0) return byLine;
final byParent =
(a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0);
if (byParent != 0) return byParent;
return (a.komponentenArtikelNr ?? '')
.compareTo(b.komponentenArtikelNr ?? '');
});
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_SectionHeader(text: 'Artikel'),
const SizedBox(height: 8),
if (delivery.state != DeliveryState.active) ...[
const _LockedHint(
text: 'Nur bei aktiver Lieferung änderbar.',
),
const SizedBox(height: 8),
],
if (items.isEmpty)
const _EmptyHint(text: 'Keine Artikel hinterlegt.')
else
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (int i = 0; i < items.length; i++) ...[
_ArticleManagementRow(
item: items[i],
details: details,
deliveryId: delivery.id,
deliveryActive: delivery.state == DeliveryState.active,
),
if (i < items.length - 1)
const Divider(height: 1, indent: 16, endIndent: 16),
],
],
),
),
const SizedBox(height: 24),
_SectionHeader(text: 'Gutschriften'),
const SizedBox(height: 8),
Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: DiscountEditor(
deliveryId: delivery.id,
active: delivery.state == DeliveryState.active,
),
),
),
],
);
}
}
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.text});
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: Text(
text,
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
);
}
}
/// Eine Zeile in der Artikel-Verwaltung. Quelle der Wahrheit ist
/// `item.scanProgress.creditedQuantity` (vom Backend) — kein lokaler Draft.
/// Zeigt:
/// - verbleibende Liefermenge (Soll Gutschrift)
/// - Gutschrift-Button → Mengen-Dialog (1…Restmenge + Grund), gesperrt für
/// scannbare, noch nicht gescannte Positionen oder inaktive Lieferung
/// - Wiederherstellen-Button, sobald etwas gutgeschrieben ist
class _ArticleManagementRow extends StatelessWidget {
const _ArticleManagementRow({
required this.item,
required this.details,
required this.deliveryId,
required this.deliveryActive,
});
final DeliveryItem item;
final TourDetails details;
final String deliveryId;
final bool deliveryActive;
Future<void> _openCreditDialog(
BuildContext context, {
required int remaining,
}) async {
final tourBloc = context.read<TourBloc>();
final actorCarId = _actorCarId(context);
// Identische Auswahl wie im Beladen-/Scan-Screen: Grund-Picker
// (Presets + „Anderer Grund") + Mengen-Stepper (Teilmengen-Gutschrift).
final result = await showReasonPickerSheet(
context: context,
title: 'Grund für das Entfernen',
presets: ReasonCatalog.itemRemove,
confirmLabel: 'Entfernen',
maxQuantity: remaining,
);
if (result == null) return;
// Eine Aktion für ganz UND teilweise: das Backend kippt die Zeile auf
// `removed`, sobald die volle Menge gutgeschrieben ist.
tourBloc.add(RemoveItem(
deliveryItemId: item.id,
reason: result.reason,
actorCarId: actorCarId,
quantity: result.quantity,
// Gutschrift-Grund zusätzlich als Lieferungs-Notiz festhalten.
saveReasonAsNote: true,
));
}
void _restoreAll(BuildContext context) {
// quantity: null → gesamte Gutschrift zurücknehmen.
context.read<TourBloc>().add(UnremoveItem(
deliveryItemId: item.id,
actorCarId: _actorCarId(context),
));
}
/// Holt die aktuelle Auto-ID aus dem CarSelectBloc — gleiche Konvention
/// wie der Loading-Flow: das aktiv gewählte Fahrzeug ist der „Akteur"
/// des Audit-Events. Falls (defensiv) noch keine Auswahl getroffen ist,
/// fallback auf einen Null-UUID-String, damit der Backend-Call nicht
/// validation-failt.
String _actorCarId(BuildContext context) {
final state = context.read<CarSelectBloc>().state;
if (state is CarSelectComplete) return state.selectedCar.id;
return '00000000-0000-0000-0000-000000000000';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final article = details.articleOf(item.articleId);
final warehouse = details.warehouseOf(item.warehouseId);
final required = item.requiredQuantity;
final credited = item.scanProgress.creditedQuantity;
final remaining = required - credited;
final fullyRemoved = item.isRemoved; // Status == removed (voll gutgeschr.)
final partiallyCredited = credited > 0 && !fullyRemoved;
// Gate: scannbare Position muss `done` sein, sonst keine Gutschrift.
final scannable = article?.scannable ?? false;
final isDone = item.scanProgress.status == ScanStatus.done;
final blockedByScan = scannable && !isDone && !fullyRemoved;
final canCredit = deliveryActive && !blockedByScan && remaining > 0;
final Color avatarColor;
final String avatarText;
if (fullyRemoved) {
avatarColor = Colors.red.shade400;
avatarText = '0×';
} else if (partiallyCredited) {
avatarColor = Colors.amber.shade700;
avatarText = '$remaining×';
} else {
avatarColor = theme.colorScheme.primary;
avatarText = '$required×';
}
return ListTile(
// Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber).
contentPadding:
EdgeInsets.only(left: item.isComponent ? 40 : 16, right: 16),
leading: CircleAvatar(
backgroundColor: avatarColor,
foregroundColor: theme.colorScheme.onPrimary,
child: Text(
avatarText,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
),
title: Text(
'${item.isComponent ? '' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}',
style: TextStyle(
fontWeight: FontWeight.w600,
decoration: fullyRemoved ? TextDecoration.lineThrough : null,
color: fullyRemoved ? theme.colorScheme.onSurfaceVariant : null,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
[
article?.articleNumber ?? item.articleId,
if (warehouse != null) warehouse.name,
if (article?.scannable == false) 'Dienstleistung',
].join(' · '),
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (fullyRemoved)
_StatusLine(
text: 'Komplett gutgeschrieben'
'${item.scanProgress.heldReason != null ? ' ${item.scanProgress.heldReason}' : ''}',
color: Colors.red.shade400,
)
else if (partiallyCredited)
_StatusLine(
text: '$credited von $required gutgeschrieben',
color: Colors.amber.shade800,
),
if (blockedByScan)
_StatusLine(
text: 'Erst scannen/verladen — dann Gutschrift möglich',
color: theme.colorScheme.onSurfaceVariant,
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (credited > 0)
IconButton(
// Wiederherstellen nur bei aktiver Lieferung — bei
// abgeschlossener/abgebrochener Lieferung gesperrt (greift auch
// backend-seitig, hier zusätzlich in der UI).
tooltip: deliveryActive
? 'Gutschrift zurücknehmen'
: 'Nur bei aktiver Lieferung',
icon: Icon(
Icons.restore,
color: deliveryActive
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
onPressed: deliveryActive ? () => _restoreAll(context) : null,
),
if (!fullyRemoved)
IconButton.outlined(
tooltip: blockedByScan
? 'Erst scannen/verladen'
: (!deliveryActive
? 'Nur bei aktiver Lieferung'
: 'Gutschrift / entfernen'),
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
canCredit
? Colors.redAccent
: theme.colorScheme.surfaceContainerHighest,
),
),
onPressed: canCredit
? () => _openCreditDialog(context, remaining: remaining)
: null,
icon: Icon(
Icons.delete,
color: canCredit
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
/// Kleine farbige Statuszeile unter dem Artikelnamen.
class _StatusLine extends StatelessWidget {
const _StatusLine({required this.text, required this.color});
final String text;
final Color color;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
}
/// Lock-Hinweis (analog DiscountEditor): zeigt mit Schloss-Symbol an, dass die
/// Artikel-Aktionen (Entfernen / Wiederherstellen) nur bei aktiver Lieferung
/// möglich sind.
class _LockedHint extends StatelessWidget {
const _LockedHint({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.lock_outline,
size: 16, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
}

View File

@ -1,25 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_options.dart';
import 'package:hl_lieferservice/model/delivery.dart' as model;
class DeliveryStepOptions extends StatefulWidget {
final model.Delivery delivery;
const DeliveryStepOptions({required this.delivery, super.key});
@override
State<StatefulWidget> createState() => _DeliveryStepInfo();
}
class _DeliveryStepInfo extends State<DeliveryStepOptions> {
@override
Widget build(BuildContext context) {
debugPrint(
"${widget.delivery.options.map((option) => "${option.display}, ${option.value}")}",
);
return DeliveryOptionsView(
options: widget.delivery.options,
deliveryId: widget.delivery.id,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,86 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_state.dart';
import 'package:hl_lieferservice/feature/delivery/detail/model/note.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_fail_page.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_overview.dart';
import 'package:hl_lieferservice/model/delivery.dart';
class DeliveryStepNote extends StatefulWidget {
final Delivery delivery;
const DeliveryStepNote({required this.delivery, super.key});
@override
State<StatefulWidget> createState() => _DeliveryStepInfo();
}
class _DeliveryStepInfo extends State<DeliveryStepNote> {
@override
void initState() {
super.initState();
context.read<NoteBloc>().add(LoadNote(delivery: widget.delivery));
}
Widget _notesLoading() {
return Center(child: CircularProgressIndicator());
}
Widget _blocUndefinedState() {
return Center(child: const Text("NoteBloc in einem Fehlerhaften Zustand"));
}
Widget _notesOverview(
BuildContext context,
List<Note> notes,
List<NoteTemplate> templates,
List<ImageNote> images,
) {
List<NoteInformation> hydratedNotes =
notes
.map(
(note) => NoteInformation(
note: note,
article: widget.delivery.findArticleWithNoteId(
note.id.toString(),
),
),
)
.toList();
return NoteOverview(
notes: hydratedNotes,
deliveryId: widget.delivery.id,
templates: templates,
images: images,
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<NoteBloc, NoteState>(
builder: (context, state) {
if (state is NoteLoading) {
return _notesLoading();
}
if (state is NoteLoaded) {
return _notesOverview(
context,
state.notes,
(state.templates ?? []),
(state.images ?? []),
);
}
if (state is NoteLoadingFailed) {
return NoteLoadingFailPage(delivery: widget.delivery);
}
return _blocUndefinedState();
},
);
}
}

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

View File

@ -0,0 +1,324 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
import 'package:hl_lieferservice/domain/entity/service.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.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/feature/delivery/bloc/tour_state.dart';
/// Step 4 — Services (früher „Lieferoptionen").
///
/// Rendert die aktiven Service-Definitionen (`TourDetails.services`,
/// admin-konfigurierbar) und lässt den Fahrer sie pro Lieferung auswählen:
/// `boolean` → Checkbox, `numeric` → Zahlenfeld mit min/max. Werte landen
/// über den `TourBloc` (`SetDeliveryServiceValue`/`RemoveDeliveryServiceValue`)
/// im Backend. Setzen nur bei aktiver Lieferung.
class StepServices extends StatelessWidget {
const StepServices({super.key, required this.delivery, required this.details});
final Delivery delivery;
final TourDetails details;
String _actorCarId(BuildContext context) {
final state = context.read<CarSelectBloc>().state;
if (state is CarSelectComplete) return state.selectedCar.id;
return '00000000-0000-0000-0000-000000000000';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final active = delivery.state == DeliveryState.active;
return BlocBuilder<TourBloc, TourState>(
buildWhen: (a, b) {
if (a is! TourLoaded || b is! TourLoaded) return true;
return a.details.services != b.details.services ||
a.details.serviceValuesByDeliveryId[delivery.id] !=
b.details.serviceValuesByDeliveryId[delivery.id];
},
builder: (context, state) {
final d = state is TourLoaded ? state.details : details;
final services = d.services;
if (services.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.construction_outlined,
size: 56, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 12),
Text('Keine Services konfiguriert',
style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Ein Administrator kann Services anlegen.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
// Wie im alten `delivery_options.dart`: zwei Kategorien —
// „Auswählbare Optionen" (Checkboxen) und „Zahlenwerte".
final bools =
services.where((s) => s.kind == ServiceKind.boolean).toList();
final numerics =
services.where((s) => s.kind == ServiceKind.numeric).toList();
_ServiceTile tileFor(Service service) => _ServiceTile(
service: service,
value: d.serviceValueOf(delivery.id, service.id),
enabled: active,
onSetBool: (v) => context.read<TourBloc>().add(
SetDeliveryServiceValue(
deliveryId: delivery.id,
serviceId: service.id,
boolValue: v,
actorCarId: _actorCarId(context),
),
),
onSetNumeric: (n) => context.read<TourBloc>().add(
SetDeliveryServiceValue(
deliveryId: delivery.id,
serviceId: service.id,
numericValue: n,
actorCarId: _actorCarId(context),
),
),
onClear: () => context.read<TourBloc>().add(
RemoveDeliveryServiceValue(
deliveryId: delivery.id,
serviceId: service.id,
),
),
);
Widget sectionCard(List<Service> items) => Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (int i = 0; i < items.length; i++) ...[
tileFor(items[i]),
if (i < items.length - 1)
const Divider(height: 1, indent: 16, endIndent: 16),
],
],
),
);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
if (!active)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Icon(Icons.lock_outline,
size: 16, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
Text(
'Nur bei aktiver Lieferung änderbar.',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (bools.isNotEmpty) ...[
const _CategoryHeader(
icon: Icons.check_box_outlined,
text: 'Auswählbare Optionen',
),
const SizedBox(height: 8),
sectionCard(bools),
],
if (bools.isNotEmpty && numerics.isNotEmpty)
const SizedBox(height: 24),
if (numerics.isNotEmpty) ...[
const _CategoryHeader(
icon: Icons.pin_outlined,
text: 'Zahlenwerte',
),
const SizedBox(height: 8),
sectionCard(numerics),
],
],
);
},
);
}
}
/// Kategorie-Überschrift (Icon + Titel) — trennt Checkboxen von Zahlenwerten.
class _CategoryHeader extends StatelessWidget {
const _CategoryHeader({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Icon(icon, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text(
text,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
);
}
}
/// Eine Service-Zeile — Checkbox (boolean) oder Zahlenfeld (numeric).
class _ServiceTile extends StatelessWidget {
const _ServiceTile({
required this.service,
required this.value,
required this.enabled,
required this.onSetBool,
required this.onSetNumeric,
required this.onClear,
});
final Service service;
final DeliveryServiceValue? value;
final bool enabled;
final ValueChanged<bool> onSetBool;
final ValueChanged<int> onSetNumeric;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
switch (service.kind) {
case ServiceKind.boolean:
return CheckboxListTile(
value: value?.boolValue ?? false,
onChanged: enabled ? (v) => onSetBool(v ?? false) : null,
title: Text(service.name),
controlAffinity: ListTileControlAffinity.leading,
dense: true,
);
case ServiceKind.numeric:
return _NumericServiceField(
key: ValueKey(service.id),
service: service,
initial: value?.numericValue,
enabled: enabled,
onSetNumeric: onSetNumeric,
onClear: onClear,
);
}
}
}
/// Zahlenfeld eines numerischen Service — eigener Controller, persistiert beim
/// Verlassen/Submit, klemmt auf [min,max]. Leeres Feld → Wert entfernen.
class _NumericServiceField extends StatefulWidget {
const _NumericServiceField({
super.key,
required this.service,
required this.initial,
required this.enabled,
required this.onSetNumeric,
required this.onClear,
});
final Service service;
final int? initial;
final bool enabled;
final ValueChanged<int> onSetNumeric;
final VoidCallback onClear;
@override
State<_NumericServiceField> createState() => _NumericServiceFieldState();
}
class _NumericServiceFieldState extends State<_NumericServiceField> {
late final TextEditingController _controller =
TextEditingController(text: widget.initial?.toString() ?? '');
@override
void didUpdateWidget(_NumericServiceField old) {
super.didUpdateWidget(old);
// Server-Stand übernehmen, wenn er sich geändert hat (z. B. nach
// Reconcile) und sich vom angezeigten Text unterscheidet.
final incoming = widget.initial?.toString() ?? '';
if (old.initial != widget.initial && _controller.text != incoming) {
_controller.text = incoming;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _commit() {
final raw = _controller.text.trim();
if (raw.isEmpty) {
widget.onClear();
return;
}
final parsed = int.tryParse(raw);
if (parsed == null) {
_controller.text = widget.initial?.toString() ?? '';
return;
}
var n = parsed;
final min = widget.service.minValue;
final max = widget.service.maxValue;
if (min != null && n < min) n = min;
if (max != null && n > max) n = max;
if (n.toString() != _controller.text) {
_controller.text = n.toString();
}
widget.onSetNumeric(n);
}
@override
Widget build(BuildContext context) {
final s = widget.service;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _controller,
enabled: widget.enabled,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: s.name,
border: const OutlineInputBorder(),
isDense: true,
),
onTapOutside: (_) {
FocusScope.of(context).unfocus();
_commit();
},
onSubmitted: (_) => _commit(),
),
);
}
}

View File

@ -1,19 +1,514 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_summary.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart';
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
/// Step 5 — Übersicht & Abschluss.
///
/// Listet alle Artikel mit der **tatsächlich auszuliefernden Menge** auf
/// (Original-Soll minus lokaler Partial-Remove-Drafts minus
/// Komplett-Removes). Dazu Anzahlung-Anzeige, optionale Gutschrift,
/// Zahlungsmethoden-Dropdown.
///
/// Der „Unterschreiben"-Button lebt in der Bottom-Navigation des
/// Page-Wrappers; hier zeigen wir den Resümee-Block, der direkt vor der
/// Unterschrift steht.
class StepSummary extends StatelessWidget {
const StepSummary({
super.key,
required this.delivery,
required this.details,
});
class DeliveryStepSummary extends StatefulWidget {
final Delivery delivery;
final TourDetails details;
const DeliveryStepSummary({required this.delivery, super.key});
@override
State<StatefulWidget> createState() => _DeliveryStepInfo();
}
class _DeliveryStepInfo extends State<DeliveryStepSummary> {
@override
Widget build(BuildContext context) {
return DeliverySummary(delivery: widget.delivery);
return BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
builder: (context, wfState) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_SectionHeader(text: 'Ausgelieferte Artikel'),
const SizedBox(height: 8),
_DeliveredItems(
delivery: delivery,
details: details,
),
const SizedBox(height: 24),
_SectionHeader(text: 'Zahlung'),
const SizedBox(height: 8),
_PaymentSummary(
delivery: delivery,
credit: details.creditOf(delivery.id),
),
const SizedBox(height: 24),
_SectionHeader(text: 'Zahlungsmethode'),
const SizedBox(height: 8),
_PaymentMethodPicker(
delivery: delivery,
overrideId: wfState.paymentMethodOverrideId,
),
const SizedBox(height: 16),
const _SignHint(),
],
);
},
);
}
}
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 _DeliveredItems extends StatelessWidget {
const _DeliveredItems({
required this.delivery,
required this.details,
});
final Delivery delivery;
final TourDetails details;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Innerhalb einer Belegzeile: Oberartikel vor seinen Komponenten, damit
// Komponenten direkt darunter eingerückt erscheinen.
final items = List<DeliveryItem>.of(delivery.items)
..sort((a, b) {
final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr);
if (byLine != 0) return byLine;
final byParent =
(a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0);
if (byParent != 0) return byParent;
return (a.komponentenArtikelNr ?? '')
.compareTo(b.komponentenArtikelNr ?? '');
});
if (items.isEmpty) {
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Keine Artikel hinterlegt.',
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
);
}
return Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (int i = 0; i < items.length; i++) ...[
_DeliveredRow(
item: items[i],
details: details,
),
if (i < items.length - 1)
const Divider(height: 1, indent: 16, endIndent: 16),
],
],
),
);
}
}
class _DeliveredRow extends StatelessWidget {
const _DeliveredRow({
required this.item,
required this.details,
});
final DeliveryItem item;
final TourDetails details;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final article = details.articleOf(item.articleId);
// Ausgeliefert = Soll Gutschrift (vom Backend). Voll gutgeschrieben
// (status removed) ⇒ credited == required ⇒ delivered 0.
final credited = item.scanProgress.creditedQuantity;
final delivered = (item.requiredQuantity - credited).clamp(
0,
item.requiredQuantity,
);
final Color avatarColor;
if (delivered == 0) {
avatarColor = Colors.red.shade400;
} else if (delivered < item.requiredQuantity) {
avatarColor = Colors.amber.shade700;
} else {
avatarColor = Colors.green.shade600;
}
return ListTile(
// Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber).
contentPadding:
EdgeInsets.only(left: item.isComponent ? 40 : 16, right: 16),
leading: CircleAvatar(
backgroundColor: avatarColor,
foregroundColor: theme.colorScheme.onPrimary,
child: Text(
'$delivered×',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
),
title: Text(
'${item.isComponent ? '' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}',
style: TextStyle(
fontWeight: FontWeight.w600,
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
),
),
subtitle: Text(
[
if (delivered < item.requiredQuantity)
'von ${item.requiredQuantity} bestellt · Gutschrift: $credited'
else
'Artikelnr. ${article?.articleNumber ?? item.articleId}',
'${item.unitPrice.toStringAsFixed(2)} € / Stück',
].join(' · '),
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
trailing: Text(
'${item.lineTotal.toStringAsFixed(2)}',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
),
),
);
}
}
class _PaymentSummary extends StatelessWidget {
const _PaymentSummary({required this.delivery, required this.credit});
final Delivery delivery;
final DeliveryCredit? credit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Exakt aus Cent (nicht gerundet) — Gutschrift kann Cent-Beträge haben.
final creditEuros = (credit?.amountCents ?? 0) / 100.0;
// Warenwert = Σ Stückpreis × ausgelieferte Menge (entfernte/teil-entfernte
// Positionen fallen automatisch raus).
final warenwert = delivery.items
.fold<double>(0, (acc, item) => acc + item.lineTotal);
// Offener Betrag = Warenwert Anzahlung Gutschrift, nie negativ.
final open = (warenwert - delivery.prepaidAmount - creditEuros)
.clamp(0.0, double.infinity);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_SummaryRow(
icon: Icons.receipt_long_outlined,
label: 'Warenwert',
valueText: '${warenwert.toStringAsFixed(2)}',
valueColor: theme.colorScheme.onSurface,
),
const SizedBox(height: 12),
_SummaryRow(
icon: Icons.savings_outlined,
label: 'Bei Bestellung bezahlt',
valueText: ' ${delivery.prepaidAmount.toStringAsFixed(2)}',
valueColor: delivery.prepaidAmount > 0
? Colors.green.shade700
: theme.colorScheme.onSurfaceVariant,
),
if (credit != null) ...[
const SizedBox(height: 12),
_SummaryRow(
icon: Icons.card_giftcard_outlined,
label: 'Gutschrift',
valueText: ' ${(credit!.amountCents / 100).toStringAsFixed(2)}',
valueColor: Colors.amber.shade800,
subtitle: credit!.reason,
),
],
const Divider(height: 24),
_SummaryRow(
icon: Icons.account_balance_wallet_outlined,
label: 'Offener Betrag',
valueText: '${open.toStringAsFixed(2)}',
valueColor: open > 0
? theme.colorScheme.primary
: Colors.green.shade700,
emphasize: true,
),
],
),
),
);
}
}
class _SummaryRow extends StatelessWidget {
const _SummaryRow({
required this.icon,
required this.label,
required this.valueText,
required this.valueColor,
this.subtitle,
this.emphasize = false,
});
final IconData icon;
final String label;
final String valueText;
final Color valueColor;
final String? subtitle;
/// Hebt Label + Wert hervor (für den „Offener Betrag"-Abschluss).
final bool emphasize;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(icon, color: theme.colorScheme.primary),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: emphasize
? const TextStyle(fontWeight: FontWeight.w700)
: null,
),
if (subtitle != null)
Text(
subtitle!,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
Text(
valueText,
style: (emphasize
? theme.textTheme.titleLarge
: theme.textTheme.titleMedium)
?.copyWith(
fontWeight: FontWeight.w700,
color: valueColor,
),
),
],
);
}
}
class _PaymentMethodPicker extends StatelessWidget {
const _PaymentMethodPicker({
required this.delivery,
required this.overrideId,
});
final Delivery delivery;
final String? overrideId;
@override
Widget build(BuildContext context) {
return BlocBuilder<PaymentMethodsCubit, PaymentMethodsState>(
builder: (context, state) {
if (state is PaymentMethodsLoading || state is PaymentMethodsInitial) {
return const Card(
margin: EdgeInsets.zero,
child: Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('Zahlungsmethoden laden …'),
],
),
),
);
}
if (state is PaymentMethodsFailed) {
return Card(
margin: EdgeInsets.zero,
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
state.message,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
);
}
final loaded = state as PaymentMethodsLoaded;
// Ausschließlich die Backend-Methoden — keine frontend-seitige
// Fabrikation/Hardcodierung. Es werden genau die angezeigt, die im
// Backend (Postgres `payment_methods`, aktiv) hinterlegt sind.
final methods = loaded.methods;
final selectedId = overrideId ?? delivery.paymentMethodId;
// Als Dropdown-Value nur setzen, wenn die Methode tatsächlich in der
// Backend-Liste ist (sonst würde Flutter asserten). Ist die zugewiesene
// Methode zwischenzeitlich deaktiviert/entfernt, bleibt das Feld leer.
final selectedValue =
methods.any((m) => m.id == selectedId) ? selectedId : null;
// Zahlungsmethode nur bei aktiver Lieferung änderbar. Bei
// abgeschlossener/abgebrochener/pausierter Lieferung zeigt das
// Dropdown den gewählten Stand, ist aber gesperrt.
final active = delivery.state == DeliveryState.active;
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
initialValue: selectedValue,
decoration: const InputDecoration(
labelText: 'Zahlungsmethode',
border: OutlineInputBorder(),
),
items: [
for (final m in methods)
DropdownMenuItem(
value: m.id,
child: Text(m.name),
),
],
// `null` deaktiviert das Dropdown (Flutter-Konvention).
onChanged: active
? (newId) {
if (newId == null) return;
context.read<DeliveryWorkflowBloc>().add(
WorkflowOverridePaymentMethod(
// Zurück auf die Original-Methode → Override
// löschen, damit das Domain-Modell "no
// override" kennt.
paymentMethodId:
newId == delivery.paymentMethodId
? null
: newId,
),
);
}
: null,
),
if (!active) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.lock_outline,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
'Lieferung abgeschlossen — Zahlungsmethode nicht '
'mehr änderbar.',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
),
],
),
],
],
),
),
);
},
);
}
}
class _SignHint extends StatelessWidget {
const _SignHint();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(Icons.draw_outlined, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
'Mit „Unterschreiben" unten schließt der Kunde den Vorgang ab.',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.primary,
),
),
),
],
),
);
}
}