A) Nach erfolgreichem Abschluss (aktiv→completed) poppt die Detail-Page automatisch zurück zur Übersicht. Scaffold ist jetzt StatefulWidget mit BlocListener<TourBloc>; nur „gearmt", wenn die Lieferung beim Öffnen aktiv war → erneutes Öffnen einer fertigen Lieferung poppt nicht. B) Step „Info": Artikelliste zeigt weiter die Ursprungsmenge (requiredQuantity). Bei entfernten/teilweise gutgeschriebenen Positionen erscheint pro Zeile ein „Menge geändert"-Hinweis + ein tappbares Banner, das zu Step 3 „Artikel" springt. C) Beladen: nicht-scanbare Set-Köpfe (Parent-Komponenten) werden jetzt IMMER mit ihrem Set gezeigt — als Kopf in der Lagergruppe ihrer Komponenten statt isoliert unter „Dienstleistungen". _ItemRow leitet scanNotRequired aus der Artikel-Scanbarkeit ab. D) Step „Übersicht": Wording der Zahlungsweise-Sperre bei offen==0 präzisiert („Keine Zahlung mehr offen (bereits bezahlt)"). E) Step „Artikel": Komponenten eines Sets sind einzeln nicht mehr entfernbar (kein Button + Hinweis). Das Entfernen/Wiederherstellen läuft nur über den Oberartikel und kaskadiert auf das ganze Set (ganz oder gar nix). Set-Entfernen ist blockiert, solange eine Komponente noch nicht verladen ist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
485 lines
18 KiB
Dart
485 lines
18 KiB
Dart
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/payment_method.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/payment_methods/bloc/payment_methods_cubit.dart';
|
||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_sign.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/delivery/detail/presentation/steps/step_articles.dart';
|
||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_info.dart';
|
||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_notes.dart';
|
||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_services.dart';
|
||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_summary.dart';
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Backend-TODOs (siehe Roadmap unten in diesem File)
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
//
|
||
// Die folgenden UI-Features sind aktuell als lokaler Stub im
|
||
// `DeliveryWorkflowBloc` gebaut. Sie persistieren nichts am Server und
|
||
// gehen verloren, sobald die Detail-Page geschlossen wird:
|
||
//
|
||
// * **B1 Bild-Notizen**: Foto-Upload-Endpoint fehlt. Domain-Feld
|
||
// `DeliveryNote.image_attachment` existiert bereits als
|
||
// Storage-Key — wir brauchen einen Endpoint, der Multipart-Upload
|
||
// entgegennimmt und den Key zurückgibt, dann hier
|
||
// `tourBloc.add(AddDeliveryNote(imageAttachment: key))` aufrufen.
|
||
//
|
||
// * ~~B4 Zahlungsmethode beim Abschluss ändern~~ — ERLEDIGT: Die im
|
||
// Summary gewählte Methode (`paymentMethodOverrideId` im Workflow-State)
|
||
// reist beim Abschluss am `/complete`-Endpoint mit und wird atomar auf
|
||
// der Lieferung persistiert (Server prüft existiert + aktiv).
|
||
//
|
||
// * **B5 Unterschrift**: Signature-Pad-Bilder (Kunde + Fahrer)
|
||
// hochladen + auf der Lieferung speichern. Backend hat dafür weder
|
||
// Felder noch Endpoint. Explizit „nach der Session" verschoben.
|
||
//
|
||
// * **B6 Notiz-Templates**: Stammdaten (vordefinierte Notiz-Texte
|
||
// zum Auswählen). Im alten Stand schon UI-seitig im NoteAddDialog
|
||
// vorbereitet; aktuell zeigen wir nur das freie Textfeld.
|
||
//
|
||
// * **B7 `completeDelivery` im Frontend**: Repository-Methode +
|
||
// Bloc-Event fehlt. Backend-Endpoint existiert (parameterlos).
|
||
// Trigger: „Unterschreiben"-Button im Summary-Step — derzeit
|
||
// SnackBar-Stub.
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Multi-Step Detail-Page einer einzelnen Lieferung. Hülle für 5 Steps;
|
||
/// jeder Step bekommt die aktuelle `Delivery` + `TourDetails` als Props,
|
||
/// damit die Steps keine eigenen Bloc-Subscriptions auf die Tour brauchen.
|
||
///
|
||
/// Lifetime: die Page bringt ihren eigenen `DeliveryWorkflowBloc` mit
|
||
/// (siehe `BlocProvider` unten). Beim Pop wird der Bloc samt Drafts
|
||
/// disposed — gewollt, denn ein neuer Besuch der Detail-Page startet
|
||
/// frisch im Step „Info".
|
||
class DeliveryDetail extends StatelessWidget {
|
||
const DeliveryDetail({super.key, required this.deliveryId});
|
||
|
||
final String deliveryId;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return BlocProvider(
|
||
create: (_) => DeliveryWorkflowBloc(deliveryId: deliveryId),
|
||
child: _DeliveryDetailScaffold(deliveryId: deliveryId),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DeliveryDetailScaffold extends StatefulWidget {
|
||
const _DeliveryDetailScaffold({required this.deliveryId});
|
||
|
||
final String deliveryId;
|
||
|
||
@override
|
||
State<_DeliveryDetailScaffold> createState() =>
|
||
_DeliveryDetailScaffoldState();
|
||
}
|
||
|
||
class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> {
|
||
/// „Gearmt" = die Lieferung war während dieser Page-Session aktiv. Nur dann
|
||
/// poppen wir bei `completed` automatisch zurück zur Übersicht. Öffnet der
|
||
/// Fahrer eine bereits abgeschlossene Lieferung, bleibt `_armed == false`
|
||
/// und die Page bleibt offen (kein ungewolltes Zurückspringen).
|
||
bool _armed = false;
|
||
bool _popped = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final s = context.read<TourBloc>().state;
|
||
if (s is TourLoaded && _findDelivery(s.details)?.state == DeliveryState.active) {
|
||
_armed = true;
|
||
}
|
||
}
|
||
|
||
Delivery? _findDelivery(TourDetails details) {
|
||
for (final d in details.deliveries) {
|
||
if (d.id == widget.deliveryId) return d;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Nach erfolgreichem Abschluss (aktiv → completed) zurück zur Übersicht.
|
||
void _onTourState(BuildContext context, TourState state) {
|
||
if (_popped || state is! TourLoaded) return;
|
||
final d = _findDelivery(state.details);
|
||
if (d == null) return;
|
||
if (d.state == DeliveryState.active) {
|
||
_armed = true;
|
||
} else if (_armed && d.state == DeliveryState.completed) {
|
||
_popped = true;
|
||
Navigator.of(context).pop();
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return BlocListener<TourBloc, TourState>(
|
||
listener: _onTourState,
|
||
child: BlocBuilder<TourBloc, TourState>(
|
||
builder: (context, tourState) {
|
||
if (tourState is! TourLoaded) {
|
||
return const Scaffold(
|
||
body: Center(child: CircularProgressIndicator()),
|
||
);
|
||
}
|
||
final details = tourState.details;
|
||
final delivery = _findDelivery(details);
|
||
if (delivery == null) {
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('Lieferung')),
|
||
body: Center(
|
||
child: Text(
|
||
'Lieferung ${widget.deliveryId} nicht in der Tour gefunden.',
|
||
),
|
||
),
|
||
);
|
||
}
|
||
final customer = details.customerOf(delivery);
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
backgroundColor: theme.primaryColor,
|
||
foregroundColor: theme.colorScheme.onPrimary,
|
||
title: Text(customer?.name ?? 'Lieferung'),
|
||
),
|
||
body: Column(
|
||
children: [
|
||
const _StepHeader(),
|
||
const Divider(height: 1),
|
||
Expanded(
|
||
child: _StepBody(delivery: delivery, details: details),
|
||
),
|
||
const Divider(height: 1),
|
||
_BottomNav(delivery: delivery, details: details),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─── Step-Header (Pills) ────────────────────────────────────────────────
|
||
|
||
class _StepHeader extends StatelessWidget {
|
||
const _StepHeader();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
|
||
builder: (context, state) {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(8, 10, 8, 10),
|
||
child: Row(
|
||
children: [
|
||
for (int i = 0; i < WorkflowStep.values.length; i++) ...[
|
||
Expanded(
|
||
child: _StepPill(
|
||
index: i,
|
||
step: WorkflowStep.values[i],
|
||
isActive: state.step.index == i,
|
||
isPassed: state.step.index > i,
|
||
onTap: () => context
|
||
.read<DeliveryWorkflowBloc>()
|
||
.add(WorkflowGoToStep(WorkflowStep.values[i])),
|
||
),
|
||
),
|
||
if (i < WorkflowStep.values.length - 1)
|
||
_StepConnector(isPassed: state.step.index > i),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _StepPill extends StatelessWidget {
|
||
const _StepPill({
|
||
required this.index,
|
||
required this.step,
|
||
required this.isActive,
|
||
required this.isPassed,
|
||
required this.onTap,
|
||
});
|
||
|
||
final int index;
|
||
final WorkflowStep step;
|
||
final bool isActive;
|
||
final bool isPassed;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final primary = theme.colorScheme.primary;
|
||
|
||
final Color circleColor;
|
||
final Color circleFg;
|
||
final Color labelColor;
|
||
final FontWeight labelWeight;
|
||
|
||
if (isActive) {
|
||
circleColor = primary;
|
||
circleFg = theme.colorScheme.onPrimary;
|
||
labelColor = primary;
|
||
labelWeight = FontWeight.w700;
|
||
} else if (isPassed) {
|
||
circleColor = primary.withValues(alpha: 0.85);
|
||
circleFg = theme.colorScheme.onPrimary;
|
||
labelColor = primary;
|
||
labelWeight = FontWeight.w600;
|
||
} else {
|
||
circleColor = theme.colorScheme.surfaceContainerHighest;
|
||
circleFg = theme.colorScheme.onSurfaceVariant;
|
||
labelColor = theme.colorScheme.onSurfaceVariant;
|
||
labelWeight = FontWeight.w500;
|
||
}
|
||
|
||
return InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
Container(
|
||
width: 30,
|
||
height: 30,
|
||
decoration: BoxDecoration(
|
||
color: circleColor,
|
||
shape: BoxShape.circle,
|
||
border: isActive
|
||
? Border.all(color: primary, width: 2)
|
||
: null,
|
||
),
|
||
child: Center(
|
||
child: isPassed && !isActive
|
||
? Icon(Icons.check, color: circleFg, size: 16)
|
||
: Text(
|
||
'${index + 1}',
|
||
style: TextStyle(
|
||
color: circleFg,
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 13,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
step.shortName,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: labelColor,
|
||
fontSize: 11,
|
||
fontWeight: labelWeight,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _StepConnector extends StatelessWidget {
|
||
const _StepConnector({required this.isPassed});
|
||
final bool isPassed;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 18),
|
||
child: Container(
|
||
width: 10,
|
||
height: 2,
|
||
color: isPassed
|
||
? theme.colorScheme.primary
|
||
: theme.colorScheme.surfaceContainerHighest,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─── Step-Body Router ───────────────────────────────────────────────────
|
||
|
||
class _StepBody extends StatelessWidget {
|
||
const _StepBody({required this.delivery, required this.details});
|
||
|
||
final Delivery delivery;
|
||
final TourDetails details;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
|
||
buildWhen: (prev, curr) => prev.step != curr.step,
|
||
builder: (context, state) {
|
||
switch (state.step) {
|
||
case WorkflowStep.info:
|
||
return StepInfo(delivery: delivery, details: details);
|
||
case WorkflowStep.notes:
|
||
return StepNotes(delivery: delivery, details: details);
|
||
case WorkflowStep.articles:
|
||
return StepArticles(delivery: delivery, details: details);
|
||
case WorkflowStep.services:
|
||
return StepServices(delivery: delivery, details: details);
|
||
case WorkflowStep.summary:
|
||
return StepSummary(delivery: delivery, details: details);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─── Bottom-Navigation ──────────────────────────────────────────────────
|
||
|
||
class _BottomNav extends StatelessWidget {
|
||
const _BottomNav({required this.delivery, required this.details});
|
||
|
||
final Delivery delivery;
|
||
final TourDetails details;
|
||
|
||
/// Öffnet den zweistufigen Unterschrift-Flow (Kunde → Fahrer). Erst nach
|
||
/// beiden Unterschriften triggert die View den Backend-Abschluss via
|
||
/// `CompleteDelivery`; danach poppt sie zurück auf die Detail-Page, die
|
||
/// dann den `completed`-Status zeigt.
|
||
void _onSign(BuildContext context) {
|
||
final tourBloc = context.read<TourBloc>();
|
||
// Die im Summary-Step gewählte Zahlungsmethode lebt im Workflow-State.
|
||
// Beim Abschluss reisen wir sie mit ans Backend (atomar mit der Signatur);
|
||
// `null` = die am Beleg hinterlegte Methode bleibt.
|
||
final paymentMethodOverrideId =
|
||
context.read<DeliveryWorkflowBloc>().state.paymentMethodOverrideId;
|
||
|
||
// Offener Betrag = Warenwert − Anzahlung − Gutschrift (≥ 0). EXAKT die
|
||
// Formel aus StepSummary und dem Backend-Inkasso-Gate.
|
||
final creditEuros =
|
||
(details.creditOf(delivery.id)?.amountCents ?? 0) / 100.0;
|
||
final warenwert =
|
||
delivery.items.fold<double>(0, (acc, item) => acc + item.lineTotal);
|
||
final open = (warenwert - delivery.prepaidAmount - creditEuros)
|
||
.clamp(0.0, double.infinity)
|
||
.toDouble();
|
||
|
||
// Effektive Methode (Override > Beleg) auflösen, um Vor-Ort-Inkasso
|
||
// (Bar/EC) von „Auf Rechnung" zu unterscheiden.
|
||
final effectiveMethodId =
|
||
paymentMethodOverrideId ?? delivery.paymentMethodId;
|
||
final pmState = context.read<PaymentMethodsCubit>().state;
|
||
PaymentMethod? method;
|
||
if (pmState is PaymentMethodsLoaded) {
|
||
for (final m in pmState.methods) {
|
||
if (m.id == effectiveMethodId) {
|
||
method = m;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// Inkasso-Pflicht: offener Betrag > 0 UND Bar/EC. „Auf Rechnung" → nein.
|
||
final requiresCollection =
|
||
open > 0 && (method?.code == 'cash' || method?.code == 'ec_card');
|
||
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (routeContext) => SignatureView(
|
||
delivery: delivery,
|
||
details: details,
|
||
requiresCollection: requiresCollection,
|
||
openAmount: open,
|
||
paymentMethodLabel: method?.name ?? '',
|
||
onSigned: (result) {
|
||
tourBloc.add(CompleteDelivery(
|
||
deliveryId: delivery.id,
|
||
customerSignaturePng: result.customerSignaturePng,
|
||
driverSignaturePng: result.driverSignaturePng,
|
||
receiptConfirmed: result.receiptConfirmed,
|
||
notesAcknowledged: result.notesAcknowledged,
|
||
acknowledgedNoteIds: result.acknowledgedNoteIds,
|
||
paymentMethodId: paymentMethodOverrideId,
|
||
paymentCollected: result.paymentCollected,
|
||
));
|
||
Navigator.of(routeContext).pop();
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SafeArea(
|
||
top: false,
|
||
child: BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
|
||
builder: (context, state) {
|
||
final isFirst = state.step.index == 0;
|
||
final isLast = state.step.index == WorkflowStep.values.length - 1;
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
|
||
child: Row(
|
||
children: [
|
||
OutlinedButton.icon(
|
||
onPressed: isFirst
|
||
? null
|
||
: () => context
|
||
.read<DeliveryWorkflowBloc>()
|
||
.add(const WorkflowPreviousStep()),
|
||
icon: const Icon(Icons.arrow_back),
|
||
label: const Text('Zurück'),
|
||
),
|
||
const Spacer(),
|
||
if (isLast)
|
||
// Unterschreiben/Abschließen nur bei aktiver Lieferung.
|
||
// Ist sie bereits abgeschlossen (oder pausiert/abgebrochen),
|
||
// bleibt der Button gesperrt.
|
||
Builder(builder: (context) {
|
||
final isActive =
|
||
delivery.state == DeliveryState.active;
|
||
final isCompleted =
|
||
delivery.state == DeliveryState.completed;
|
||
return FilledButton.icon(
|
||
onPressed: isActive ? () => _onSign(context) : null,
|
||
icon: Icon(isCompleted
|
||
? Icons.check_circle_outline
|
||
: Icons.draw_outlined),
|
||
label: Text(
|
||
isCompleted ? 'Abgeschlossen' : 'Unterschreiben',
|
||
),
|
||
);
|
||
})
|
||
else
|
||
FilledButton.icon(
|
||
onPressed: () => context
|
||
.read<DeliveryWorkflowBloc>()
|
||
.add(const WorkflowNextStep()),
|
||
icon: const Icon(Icons.arrow_forward),
|
||
label: const Text('Weiter'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|