Files
Holzleitner-Lieferservice-App/lib/feature/delivery/detail/presentation/delivery_detail_page.dart
Dennis Nemec 6b934e4534 feat(abschluss): interne Notiz nach den Unterschriften abfragen
- Nach "Abschliessen" oeffnet sich ein Blatt "Interne Notiz" (optional,
  max. 2000 Zeichen). Ohne Eingabe genuegt ein Tipp auf "Ohne Notiz
  abschliessen"; "Zurueck zur Unterschrift" bricht nur das Abschliessen ab
- Die Notiz reist im Abschluss-Aufruf mit (internalNote) und landet im
  Lieferbericht, nicht in den Kunden-Notizen
- API-Client aus aktueller Backend-Spec neu generiert
- Widget-Tests fuer das Blatt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:52:51 +02:00

504 lines
19 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/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/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/model/delivery_payment_status.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_payment.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 und abgelöst:
// Die Zahlung wird im eigenen Step „Zahlung" abgewickelt und am Server
// protokolliert (`POST /deliveries/{id}/payment`). Beim Abschluss reist
// die Methode der gültigen Zahlung mit.
//
// * **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 6 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: [
_StepHeader(delivery: delivery, details: details),
const Divider(height: 1),
Expanded(
child: _StepBody(delivery: delivery, details: details),
),
const Divider(height: 1),
_BottomNav(delivery: delivery, details: details),
],
),
);
},
),
);
}
}
// ─── Step-Header (Pills) ────────────────────────────────────────────────
/// Steps hinter „Zahlung" sind gesperrt, solange bei einer aktiven
/// Lieferung ein offener Betrag noch nicht gültig abgewickelt ist.
bool _lockedByPayment(
WorkflowStep step,
Delivery delivery,
TourDetails details,
) {
if (step.index <= WorkflowStep.payment.index) return false;
if (delivery.state != DeliveryState.active) return false;
return !DeliveryPaymentStatus.of(delivery, details).isSettled;
}
class _StepHeader extends StatelessWidget {
const _StepHeader({required this.delivery, required this.details});
final Delivery delivery;
final TourDetails details;
@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,
enabled:
!_lockedByPayment(
WorkflowStep.values[i],
delivery,
details,
),
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.enabled,
required this.onTap,
});
final int index;
final WorkflowStep step;
final bool isActive;
final bool isPassed;
/// `false` = Step gesperrt (z. B. Übersicht vor abgewickelter Zahlung).
final bool enabled;
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: enabled ? onTap : null,
borderRadius: BorderRadius.circular(8),
child: Opacity(
opacity: enabled ? 1 : 0.4,
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.payment:
return StepPayment(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>();
// Kassiert wurde bereits im Step „Zahlung" (serverseitig protokolliert).
// Beim Abschluss reisen Methode und Inkasso-Flag der gültigen Zahlung
// mit; ohne offenen Betrag bleibt die Methode vom Beleg.
final payment = DeliveryPaymentStatus.of(delivery, details).validPayment;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder:
(routeContext) => SignatureView(
delivery: delivery,
details: details,
onSigned: (result) {
tourBloc.add(
CompleteDelivery(
deliveryId: delivery.id,
customerSignaturePng: result.customerSignaturePng,
driverSignaturePng: result.driverSignaturePng,
receiptConfirmed: result.receiptConfirmed,
notesAcknowledged: result.notesAcknowledged,
acknowledgedNoteIds: result.acknowledgedNoteIds,
paymentMethodId: payment?.paymentMethodId,
paymentCollected: payment?.isCollectedOnSite ?? false,
internalNote: result.internalNote,
),
);
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(
// Aus „Zahlung" erst weiter, wenn abgewickelt/nichts offen.
onPressed:
_lockedByPayment(
WorkflowStep.values[state.step.index + 1],
delivery,
details,
)
? null
: () => context.read<DeliveryWorkflowBloc>().add(
const WorkflowNextStep(),
),
icon: const Icon(Icons.arrow_forward),
label: const Text('Weiter'),
),
],
),
);
},
),
);
}
}