Final commit.
This commit is contained in:
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
716
lib/feature/delivery/detail/presentation/steps/step_notes.dart
Normal file
716
lib/feature/delivery/detail/presentation/steps/step_notes.dart
Normal 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 → ~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<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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user