Final commit.
This commit is contained in:
@ -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