Final commit.

This commit is contained in:
Dennis Nemec
2026-06-01 17:12:28 +02:00
parent 3ecbc82885
commit a9bf8ecdd1
385 changed files with 29081 additions and 12089 deletions

View File

@ -13,8 +13,17 @@ import 'package:hl_lieferservice/feature/delivery/overview/service/phase_service
/// Default-Eintritt [DeliveryPhase.sortieren].
typedef CarCountResolver = int? Function();
/// Liefert einen Token, der die aktuell geladene Tour-Version identifiziert
/// (typischerweise aus `Tour.syncedAt`). Der [PhaseService] bindet die
/// persistierten Phasen an diesen Token — ein neuer ERP-Sync / Demo-Seed
/// erzeugt einen neuen Token und damit einen frischen Phasen-Stand.
///
/// Liefert `null`, wenn (noch) keine Tour geladen ist; der BLoC nutzt dann
/// einen neutralen Fallback-Token.
typedef TourTokenResolver = String? Function();
/// Zentraler State für die aktuelle Phase je Fahrzeug. Persistiert über
/// [PhaseService] auf datumsbezogene SharedPreferences-Keys (siehe Service).
/// [PhaseService] auf tour-token-bezogene SharedPreferences-Keys (siehe Service).
///
/// Eintrittsphase nach Fahrzeugauswahl:
/// * 1 Auto im Team → [DeliveryPhase.sortieren] (bisheriges Verhalten).
@ -30,9 +39,14 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
/// Provider so verdrahtet, dass sie aus dem [TourBloc] kommt.
final CarCountResolver? carCountResolver;
/// Liefert den Tour-Token (aus `Tour.syncedAt`). Bindet die persistierten
/// Phasen an die aktuelle Tour-Version.
final TourTokenResolver? tourTokenResolver;
PhaseBloc({
PhaseService? phaseService,
this.carCountResolver,
this.tourTokenResolver,
}) : phaseService = phaseService ?? PhaseService(),
super(PhaseInitial()) {
on<PhaseLoadForCar>(_load);
@ -40,6 +54,11 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
on<PhaseSet>(_set);
}
/// Aktueller Tour-Token oder neutraler Fallback, falls noch keine Tour
/// geladen ist (z. B. `TourEmpty`) — dort ist die Phase ohnehin
/// bedeutungslos.
String _token() => tourTokenResolver?.call() ?? 'no-tour';
PhaseReady _ensureReady() {
final current = state;
return current is PhaseReady
@ -62,8 +81,9 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
if (current.phaseByCar.containsKey(event.carId)) return;
try {
final persisted = await phaseService.load(event.carId);
final persistedMax = await phaseService.loadMax(event.carId);
final token = _token();
final persisted = await phaseService.load(event.carId, token);
final persistedMax = await phaseService.loadMax(event.carId, token);
final phase = persisted ?? _entryPhase();
// Max ist mindestens die aktuelle Phase. Falls in der Persistenz ein
// höherer Wert steht (Rücksprung), den nehmen.
@ -75,11 +95,11 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
if (persisted == null) {
// Erste Phase nach Fahrzeugauswahl direkt persistieren, damit
// ein Resume nach App-Neustart die Phase kennt.
await phaseService.save(event.carId, phase);
await phaseService.saveMax(event.carId, maxPhase);
await phaseService.save(event.carId, token, phase);
await phaseService.saveMax(event.carId, token, maxPhase);
} else if (persistedMax == null) {
// Migration: alte Tage ohne Max-Tracking → einmalig nachziehen.
await phaseService.saveMax(event.carId, maxPhase);
await phaseService.saveMax(event.carId, token, maxPhase);
}
add(PhaseLoaded(
@ -109,12 +129,13 @@ class PhaseBloc extends Bloc<PhaseEvent, PhaseState> {
final next = current.withPhase(event.carId, event.phase);
emit(next);
try {
await phaseService.save(event.carId, event.phase);
final token = _token();
await phaseService.save(event.carId, token, event.phase);
// withPhase hat das Max ggf. hochgezogen — persistieren, damit ein
// Neustart die "höchste erreichte Phase" kennt.
final newMax = next.maxPhaseFor(event.carId);
if (newMax != null) {
await phaseService.saveMax(event.carId, newMax);
await phaseService.saveMax(event.carId, token, newMax);
}
} catch (e, st) {
debugPrint("PhaseBloc._set: $e $st");

File diff suppressed because it is too large Load Diff

View File

@ -1,277 +1,341 @@
import 'dart:typed_data';
import 'package:hl_lieferservice/model/car.dart';
import 'package:hl_lieferservice/model/tour.dart';
import '../../../../model/delivery.dart';
abstract class TourEvent {}
/// Events für den [`TourBloc`].
///
/// Bewusst minimaler Scope für Phase C+D-2: Lese-Pfad, Reorder,
/// Car-Assign. Scan-, Hold-, Cancel-, Complete-, Discount- und
/// Notiz-Events kommen in C+D-3 / C+D-4 zurück.
sealed class TourEvent {
const TourEvent();
}
/// Initial-Load der heutigen Tour des angemeldeten Fahrers.
/// Account-Filter sitzt im JWT — kein zusätzliches Argument nötig.
class LoadTour extends TourEvent {
String teamId;
LoadTour({required this.teamId});
const LoadTour();
}
class RequestDeliveryDistanceEvent extends TourEvent {
Tour tour;
RequestDeliveryDistanceEvent({required this.tour});
/// Pull-to-refresh / manueller Reload aus der Übersicht. Vorhandener
/// `TourLoaded`-State bleibt sichtbar, bis der neue Snapshot da ist;
/// `TourLoading` wird nur emittiert, wenn vorher kein State existierte.
class RefreshTour extends TourEvent {
const RefreshTour();
}
class RequestSortingInformationEvent extends TourEvent {
Tour tour;
List<Payment> payments;
/// Schreibt die Sortier-Reihenfolge aller Lieferungen einer Tour an das
/// Backend. Der Bloc fordert die UI nicht auf, die vollständige Reihenfolge
/// zu kennen — sie wird aus der Liste übergeben.
class ReorderDeliveries extends TourEvent {
const ReorderDeliveries({required this.orderedDeliveryIds});
RequestSortingInformationEvent({
required this.tour,
required this.payments,
});
final List<String> orderedDeliveryIds;
}
class ReorderDeliveryEvent extends TourEvent {
int newPosition;
int oldPosition;
String carId;
/// Weist einer Lieferung ein Fahrzeug zu — oder hebt die Zuweisung auf
/// (`carId == null`).
class AssignCarToDelivery extends TourEvent {
const AssignCarToDelivery({required this.deliveryId, required this.carId});
ReorderDeliveryEvent({required this.newPosition, required this.oldPosition, required this.carId});
}
/// Ersetzt die komplette Sortier-Information (z. B. beim Zurücksetzen auf
/// die Default-Reihenfolge in der Sortier-Page). Persistiert lokal über
/// den ReorderService, kein Backend-Call.
class ReplaceSortingEvent extends TourEvent {
final String carId;
final Map<String, List<String>> newSortingInformation;
ReplaceSortingEvent({
required this.carId,
required this.newSortingInformation,
});
}
/// Bestätigung der Sortierung durch den Fahrer. Löst den (aktuell ge-
/// stubbten) Backend-Call zur Persistierung der Reihenfolge aus und
/// schaltet bei Erfolg in Phase [DeliveryPhase.beladen].
class ConfirmSortingEvent extends TourEvent {
final String carId;
ConfirmSortingEvent({required this.carId});
}
/// Stellt sicher, dass der Sortier-Bucket für [carId] alle aktuell in der
/// Tour vorhandenen Lieferungen enthält — und zwar UNABHÄNGIG von
/// `delivery.carId`. Hintergrund: zum Sortier-Zeitpunkt sind die Lieferungen
/// dem Fahrzeug oft noch nicht zugeordnet (das passiert erst beim Scannen
/// während des Beladens). Der Fahrer hat aber bereits sein Tagesfahrzeug
/// gewählt, also gehören alle Tour-Lieferungen in diesen Bucket. Bestehende
/// Reihenfolge bleibt erhalten, fehlende IDs werden hinten angehängt.
class EnsureSortingForCarEvent extends TourEvent {
final String carId;
EnsureSortingForCarEvent({required this.carId});
}
class TourUpdated extends TourEvent {
Tour tour;
List<Payment> payments;
TourUpdated({required this.tour, required this.payments});
}
class PaymentOptionsUpdated extends TourEvent {
List<Payment> options;
PaymentOptionsUpdated({required this.options});
}
class UpdateTour extends TourEvent {
Tour tour;
UpdateTour({required this.tour});
}
class AssignCarEvent extends TourEvent {
String deliveryId;
String carId;
AssignCarEvent({required this.deliveryId, required this.carId});
}
/// Hebt die Fahrzeug-Zuordnung einer Lieferung wieder auf. Wird im
/// Auswahl-Schritt ausgelöst, wenn der Fahrer eine eigene Lieferung
/// "freigibt", damit ein Kollege sie übernehmen kann. Aktuell ruft der
/// Handler nur den ProcessRepository-Stub auf — ein echtes Update der
/// Tour erfolgt erst mit dem realen Backend-Endpoint (siehe Repository).
class UnassignDeliveryEvent extends TourEvent {
final String deliveryId;
UnassignDeliveryEvent({required this.deliveryId});
final String? carId;
}
class IncrementArticleScanAmount extends TourEvent {
String internalArticleId;
String deliveryId;
String carId;
IncrementArticleScanAmount({
required this.internalArticleId,
required this.deliveryId,
/// Bulk-Variante: weist mehreren Lieferungen in einer atomaren Aktion das
/// gleiche Fahrzeug zu. Notwendig, weil flutter_bloc Events standardmäßig
/// **concurrent** verarbeitet — N parallel laufende `AssignCarToDelivery`-
/// Handler lesen alle den Initial-State und überschreiben sich gegenseitig
/// beim `emit`, sodass nur die letzte Zuweisung sichtbar bleibt. Der
/// Bulk-Handler verarbeitet die Liste sequenziell und emittiert genau
/// einen Zwischen-State am Ende.
///
/// Backend-seitig macht der Handler trotzdem N einzelne HTTP-Calls, weil
/// der `PATCH /deliveries/{id}/assigned-car`-Endpoint keine Liste kennt.
class AssignCarToDeliveries extends TourEvent {
const AssignCarToDeliveries({
required this.deliveryIds,
required this.carId,
});
final List<String> deliveryIds;
final String? carId;
}
class ScanArticleEvent extends TourEvent {
ScanArticleEvent({
required this.articleNumber,
required this.carId,
required this.deliveryId,
/// Scan-Trigger aus der Loading-Phase. Der Bloc inkrementiert lokal
/// **optimistisch** die Scan-Quantität, ruft anschließend `POST /scans`
/// und rollt im `rejected`-Fall lokal zurück. `duplicate` ist still
/// (lokal schon hochgezählt, Server bestätigt nicht erneut).
///
/// `actorCarId` ist die UUID des aktuell gewählten Fahrzeugs — das
/// Backend nutzt das nur als Audit-Spur; an der Lieferung selbst
/// passiert dadurch nichts.
class ScanItem extends TourEvent {
const ScanItem({
required this.deliveryItemId,
required this.actorCarId,
this.manual = false,
});
String articleNumber;
String deliveryId;
String carId;
final String deliveryItemId;
final String actorCarId;
/// `true` = manuelle Zeilen-Bestätigung (Fallback ohne Barcode): die
/// **gesamte Restmenge** wird auf einmal als geladen verbucht und im
/// Backend als `manual` protokolliert. `false` = regulärer Einzel-Scan (+1).
final bool manual;
}
/// Scan a single BOM component. The server call for the parent article is
/// deferred until *all* components are fully scanned.
class ScanComponentEvent extends TourEvent {
ScanComponentEvent({
required this.componentArticleNumber,
required this.carId,
required this.deliveryId,
});
String componentArticleNumber;
String deliveryId;
String carId;
}
class CancelDeliveryEvent extends TourEvent {
String deliveryId;
CancelDeliveryEvent({required this.deliveryId});
}
class HoldDeliveryEvent extends TourEvent {
String deliveryId;
HoldDeliveryEvent({required this.deliveryId});
}
class ReactivateDeliveryEvent extends TourEvent {
String deliveryId;
ReactivateDeliveryEvent({required this.deliveryId});
}
class LoadDeliveryEvent extends TourEvent {
LoadDeliveryEvent({required this.delivery});
Delivery delivery;
}
class UnscanArticleEvent extends TourEvent {
UnscanArticleEvent({
required this.articleId,
required this.newAmount,
required this.reason,
required this.deliveryId,
});
String articleId;
String deliveryId;
String reason;
int newAmount;
}
class ResetScanAmountEvent extends TourEvent {
ResetScanAmountEvent({required this.articleId, required this.deliveryId});
String articleId;
String deliveryId;
}
class AddDiscountEvent extends TourEvent {
AddDiscountEvent({
required this.deliveryId,
required this.value,
/// Umkehrung eines Scans (z. B. „falsch gescannt"). `reason` ist vom
/// Backend für `unscan` erforderlich.
class UnscanItem extends TourEvent {
const UnscanItem({
required this.deliveryItemId,
required this.actorCarId,
required this.reason,
});
String deliveryId;
String reason;
int value;
final String deliveryItemId;
final String actorCarId;
final String reason;
}
class RemoveDiscountEvent extends TourEvent {
RemoveDiscountEvent({required this.deliveryId});
String deliveryId;
}
class UpdateDiscountEvent extends TourEvent {
UpdateDiscountEvent({
required this.deliveryId,
required this.value,
/// Pausiert ein Item (`scan_status=held`). Reversibel über [UnholdItem].
/// `reason` Pflicht.
class HoldItem extends TourEvent {
const HoldItem({
required this.deliveryItemId,
required this.actorCarId,
required this.reason,
});
String deliveryId;
String? reason;
int? value;
final String deliveryItemId;
final String actorCarId;
final String reason;
}
class CarsLoadedEvent extends TourEvent {
List<Car> cars;
CarsLoadedEvent({required this.cars});
}
class UpdateDeliveryOptionEvent extends TourEvent {
UpdateDeliveryOptionEvent({
required this.key,
required this.value,
required this.deliveryId,
/// Setzt ein pausiertes Item zurück auf `in_progress`. Kein Reason
/// nötig — der Server löscht beim Unhold den `held_reason`.
class UnholdItem extends TourEvent {
const UnholdItem({
required this.deliveryItemId,
required this.actorCarId,
});
String deliveryId;
String key;
dynamic value;
final String deliveryItemId;
final String actorCarId;
}
class UpdateSelectedPaymentMethodEvent extends TourEvent {
UpdateSelectedPaymentMethodEvent({
required this.payment,
required this.deliveryId,
/// Entfernt ein Item aus der Lieferung (`scan_status=removed`).
/// `reason` Pflicht. Reversibel über [UnremoveItem] — die Historie
/// bleibt im Audit-Log vollständig erhalten.
class RemoveItem extends TourEvent {
const RemoveItem({
required this.deliveryItemId,
required this.actorCarId,
required this.reason,
this.quantity,
this.saveReasonAsNote = false,
});
Payment payment;
String deliveryId;
final String deliveryItemId;
final String actorCarId;
final String reason;
/// Mengen-Gutschrift: wie viele Stück gutgeschrieben werden. `null` =
/// ganze Restmenge (= klassisches „ganze Zeile entfernen").
final int? quantity;
/// Wenn `true`, wird der `reason` nach erfolgreichem Entfernen zusätzlich
/// als Lieferungs-Notiz gespeichert. Gesetzt aus dem Gutschrift-Flow
/// (Step „Artikel & Gutschriften"); im Beladen-Flow `false`, dort wäre
/// eine Notiz pro Abbuchung nur Rauschen.
final bool saveReasonAsNote;
}
class FinishDeliveryEvent extends TourEvent {
FinishDeliveryEvent({
required this.deliveryId,
required this.driverSignature,
required this.customerSignature,
/// Stellt ein entferntes Item wieder her — geht nur, wenn der aktuelle
/// Status `removed` ist. Kein Reason nötig (Backend-Konvention wie bei
/// `unscan` und `unhold`). Audit-Log behält den ursprünglichen
/// `remove`-Eintrag und fügt einen neuen `unremove`-Eintrag hinzu.
class UnremoveItem extends TourEvent {
const UnremoveItem({
required this.deliveryItemId,
required this.actorCarId,
this.quantity,
});
String deliveryId;
Uint8List customerSignature;
Uint8List driverSignature;
final String deliveryItemId;
final String actorCarId;
/// Mengen-Gutschrift zurücknehmen: wie viele Stück wiederhergestellt
/// werden. `null` = gesamte Gutschrift zurück.
final int? quantity;
}
class SetArticleAmountEvent extends TourEvent {
// ─── Delivery-Lifecycle ───────────────────────────────────────────────
/// Bricht eine Lieferung endgültig ab. `reason` ist Pflicht.
class CancelDelivery extends TourEvent {
const CancelDelivery({required this.deliveryId, required this.reason});
final String deliveryId;
final String articleId;
final String? reason;
final int amount;
final String reason;
}
SetArticleAmountEvent({
/// Pausiert eine Lieferung. `reason` ist Pflicht. Reversibel über
/// [ResumeDelivery].
class HoldDelivery extends TourEvent {
const HoldDelivery({required this.deliveryId, required this.reason});
final String deliveryId;
final String reason;
}
/// Setzt eine pausierte Lieferung zurück auf `active`.
class ResumeDelivery extends TourEvent {
const ResumeDelivery({required this.deliveryId});
final String deliveryId;
}
/// Schließt eine Lieferung ab: lädt beide Unterschriften hoch, dokumentiert
/// die Bestätigungen des Kunden und setzt die Lieferung auf `completed`.
class CompleteDelivery extends TourEvent {
const CompleteDelivery({
required this.deliveryId,
required this.articleId,
required this.amount,
this.reason,
required this.customerSignaturePng,
required this.driverSignaturePng,
required this.receiptConfirmed,
required this.notesAcknowledged,
required this.acknowledgedNoteIds,
this.paymentMethodId,
this.actorCarId,
this.paymentCollected = false,
});
}
final String deliveryId;
final List<int> customerSignaturePng;
final List<int> driverSignaturePng;
final bool receiptConfirmed;
final bool notesAcknowledged;
final List<String> acknowledgedNoteIds;
/// Vom Fahrer im Summary gewählte Zahlungsmethode (Override). `null` =
/// die am Beleg hinterlegte Methode bleibt. Wird beim Abschluss persistiert.
final String? paymentMethodId;
final String? actorCarId;
/// Fahrer hat das Vor-Ort-Inkasso (Bar/EC) des offenen Betrags bestätigt.
/// `false`, wenn kein Inkasso anfiel (offen == 0 oder „Auf Rechnung").
final bool paymentCollected;
}
/// Legt eine neue (Text- oder Bild-)Notiz an einer Lieferung an. Aktuell
/// wird nur der Text-Pfad von der UI getriggert; `imageAttachment` ist als
/// Storage-Key (z. B. Pre-Signed-URL-Key) gedacht und wartet auf die
/// zukünftige Foto-Upload-Phase.
/// Setzt/ändert die Betrags-Gutschrift einer Lieferung (Geld-Nachlass).
class SetDeliveryCredit extends TourEvent {
const SetDeliveryCredit({
required this.deliveryId,
required this.amountCents,
required this.reason,
required this.actorCarId,
});
final String deliveryId;
final int amountCents;
final String reason;
final String actorCarId;
}
/// Entfernt die Betrags-Gutschrift einer Lieferung.
class RemoveDeliveryCredit extends TourEvent {
const RemoveDeliveryCredit({
required this.deliveryId,
required this.actorCarId,
});
final String deliveryId;
final String actorCarId;
}
/// Setzt/ändert den Wert eines Service für eine Lieferung (Checkbox/Zahl).
class SetDeliveryServiceValue extends TourEvent {
const SetDeliveryServiceValue({
required this.deliveryId,
required this.serviceId,
required this.actorCarId,
this.boolValue,
this.numericValue,
});
final String deliveryId;
final String serviceId;
final String actorCarId;
final bool? boolValue;
final int? numericValue;
}
/// Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt").
class RemoveDeliveryServiceValue extends TourEvent {
const RemoveDeliveryServiceValue({
required this.deliveryId,
required this.serviceId,
});
final String deliveryId;
final String serviceId;
}
class AddDeliveryNote extends TourEvent {
const AddDeliveryNote({
required this.deliveryId,
this.text,
this.imageAttachment,
this.creditDeliveryItemId,
});
final String deliveryId;
final String? text;
final String? imageAttachment;
/// Optionaler Gutschrift-Bezug (DeliveryItem-Id). Gesetzt, wenn die Notiz
/// einen Gutschrift-Grund dokumentiert — ermöglicht das Löschen beim
/// Unremove.
final String? creditDeliveryItemId;
}
/// Ändert Text/Bild einer bestehenden Notiz.
class UpdateDeliveryNote extends TourEvent {
const UpdateDeliveryNote({
required this.deliveryId,
required this.noteId,
this.text,
this.imageAttachment,
});
final String deliveryId;
final String noteId;
final String? text;
final String? imageAttachment;
}
/// Löscht eine Notiz.
class DeleteDeliveryNote extends TourEvent {
const DeleteDeliveryNote({required this.deliveryId, required this.noteId});
final String deliveryId;
final String noteId;
}
/// Lädt ein Bild als Notiz hoch (geht über DOCUframe).
class UploadDeliveryNoteImage extends TourEvent {
const UploadDeliveryNoteImage({
required this.deliveryId,
required this.filename,
required this.mime,
required this.bytes,
});
final String deliveryId;
final String filename;
final String mime;
final List<int> bytes;
}

View File

@ -1,63 +1,95 @@
import '../../../../model/tour.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
abstract class TourState {}
/// Lifecycle-States des `TourBloc`.
///
/// Bewusst eine sealed-Hierarchie: das UI kann via `switch` exhaustiv
/// alle Pfade abbilden und der Compiler meldet, wenn ein neuer Pfad
/// dazukommt.
sealed class TourState {
const TourState();
}
class TourInitial extends TourState {}
/// App-Start, bevor irgendetwas geladen wurde.
class TourInitial extends TourState {
const TourInitial();
}
class TourLoading extends TourState {}
/// Initial-Load läuft. Wird nur emittiert, wenn vorher kein Tour-State da
/// war — für Refresh siehe `TourLoaded.isRefreshing`.
class TourLoading extends TourState {
const TourLoading();
}
class TourLoadingFailed extends TourState {}
/// Initial-Load ist gescheitert. Refresh-Fehler hingegen werden in
/// `TourLoaded.refreshError` getragen, damit die alte Tour sichtbar bleibt.
class TourLoadFailed extends TourState {
const TourLoadFailed({required this.message});
final String message;
}
/// Erfolgreich geladen — beinhaltet das volle Tour-Aggregat sowie
/// UI-relevante Zusatzflags rund um Reorder- und Refresh-Operationen.
class TourLoaded extends TourState {
Tour tour;
Map<String, double>? distances;
List<Payment> paymentOptions;
Map<String, List<String>> sortingInformation;
/// Number of scan-related server requests currently in flight. Drives the
/// inline indicator on the scanner widget. Using a counter (not bool) lets
/// rapid-fire scans coexist without one prematurely clearing the indicator.
int pendingScanRequests;
/// True während der Backend-Call zur Persistierung der Sortier-Reihenfolge
/// läuft. Wird vom Bestätigungs-Button in der Sortier-Page für Spinner
/// und Button-Disabled-State ausgewertet.
bool isPersistingSorting;
/// Letzte Fehlermeldung des Sortier-Persist-Calls. Wird nach Anzeige
/// durch das UI ge-leert (z. B. nach SnackBar).
String? sortingPersistError;
TourLoaded({
required this.tour,
this.distances,
required this.paymentOptions,
required this.sortingInformation,
this.pendingScanRequests = 0,
this.isPersistingSorting = false,
this.sortingPersistError,
const TourLoaded({
required this.details,
this.isRefreshing = false,
this.isPersistingReorder = false,
this.refreshError,
this.reorderError,
});
final TourDetails details;
/// Hintergrund-Reload läuft (Pull-to-Refresh, Provider-Wakeup). UI darf
/// die alte Daten weiter zeigen und nur einen schmalen Indikator
/// einblenden.
final bool isRefreshing;
/// `PUT /tours/{id}/delivery-order` läuft. Sortier-Page nutzt das für
/// den Bestätigungs-Button.
final bool isPersistingReorder;
/// Fehler eines Hintergrund-Reloads — bleibt für eine einzelne Snackbar
/// hängen und wird beim nächsten Reload geleert.
final String? refreshError;
/// Fehler des letzten Reorder-Persist-Versuchs.
final String? reorderError;
TourLoaded copyWith({
Tour? tour,
Map<String, double>? distances,
List<Payment>? paymentOptions,
Map<String, List<String>>? sortingInformation,
int? pendingScanRequests,
bool? isPersistingSorting,
String? sortingPersistError,
bool clearSortingPersistError = false,
TourDetails? details,
bool? isRefreshing,
bool? isPersistingReorder,
Object? refreshError = _sentinel,
Object? reorderError = _sentinel,
}) {
return TourLoaded(
tour: tour ?? this.tour,
distances: distances ?? this.distances,
paymentOptions: paymentOptions ?? this.paymentOptions,
sortingInformation: sortingInformation ?? this.sortingInformation,
pendingScanRequests: pendingScanRequests ?? this.pendingScanRequests,
isPersistingSorting: isPersistingSorting ?? this.isPersistingSorting,
sortingPersistError: clearSortingPersistError
? null
: (sortingPersistError ?? this.sortingPersistError),
details: details ?? this.details,
isRefreshing: isRefreshing ?? this.isRefreshing,
isPersistingReorder: isPersistingReorder ?? this.isPersistingReorder,
refreshError: identical(refreshError, _sentinel)
? this.refreshError
: refreshError as String?,
reorderError: identical(reorderError, _sentinel)
? this.reorderError
: reorderError as String?,
);
}
/// Spezialfall: Initial-Load ist erfolgreich, aber das Backend hat dem
/// angemeldeten Fahrer keine Tour für heute zugewiesen (kein ERP-Sync,
/// Urlaub, …). UI kann darauf einen freundlichen Hinweis statt einer
/// leeren Liste anzeigen.
bool get isEmpty => details.deliveries.isEmpty;
}
const Object _sentinel = Object();
/// Erfolgs-Spezialform für „heute keine Tour zugewiesen". Wir behandeln das
/// als eigenständigen State (statt als `TourLoaded` mit leeren Listen),
/// damit das UI im Routing klar trennen kann zwischen „Tour vorhanden,
/// gerade keine Lieferungen offen" und „gar keine Tour für heute".
class TourEmpty extends TourState {
const TourEmpty();
}

View File

@ -1,172 +0,0 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/exceptions.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
import 'package:rxdart/rxdart.dart';
import '../../../../model/delivery.dart';
import 'note_event.dart';
import 'note_state.dart';
import 'package:hl_lieferservice/feature/delivery/detail/repository/note_repository.dart';
class NoteBloc extends Bloc<NoteEvent, NoteState> {
final NoteRepository repository;
final OperationBloc opBloc;
final AuthBloc authBloc;
final String deliveryId;
StreamSubscription? _combinedSubscription;
NoteBloc({
required this.repository,
required this.opBloc,
required this.authBloc,
required this.deliveryId,
}) : super(NoteInitial()) {
_combinedSubscription = CombineLatestStream.combine3(
repository.notes,
repository.images,
repository.templates,
(note, image, templates) {
if (note == null || image == null || templates == null) {
return null;
}
return {"note": note, "image": image, "templates": templates};
},
)
.where((data) => data != null)
.listen(
(data) => add(
DataUpdated(
images: data!["image"] as List<ImageNote>,
notes: data["note"] as List<Note>,
templates: data["templates"] as List<NoteTemplate>,
),
),
);
on<LoadNote>(_load);
on<AddNote>(_add);
on<EditNote>(_edit);
on<RemoveNote>(_remove);
on<AddImageNote>(_upload);
on<RemoveImageNote>(_removeImage);
on<ResetNotes>(_reset);
on<DataUpdated>(_dataUpdated);
}
@override
Future<void> close() {
_combinedSubscription?.cancel();
return super.close();
}
void _handleError(Object e, String fallbackMessage) {
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
} else {
opBloc.add(FailOperation(message: fallbackMessage));
}
}
Future<void> _dataUpdated(DataUpdated event, Emitter<NoteState> emit) async {
emit(
NoteLoaded(
notes: event.notes,
images: event.images,
templates: event.templates,
),
);
}
Future<void> _reset(ResetNotes event, Emitter<NoteState> emit) async {
emit.call(NoteInitial());
}
Future<void> _removeImage(
RemoveImageNote event,
Emitter<NoteState> emit,
) async {
opBloc.add(StartOperation());
try {
await repository.deleteImage(event.deliveryId, event.objectId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Löschen des Bildes: $e $st");
_handleError(e, "Fehler beim Löschen des Bildes");
}
}
Future<void> _upload(AddImageNote event, Emitter<NoteState> emit) async {
opBloc.add(StartOperation());
try {
Uint8List imageBytes = await event.file.readAsBytes();
await repository.addImage(event.deliveryId, imageBytes);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen des Bildes: $e $st");
_handleError(e, "Fehler beim Hinzufügen des Bildes");
}
}
Future<void> _load(LoadNote event, Emitter<NoteState> emit) async {
if (state is NoteLoaded || state is NoteLoading) {
return;
}
emit.call(NoteLoading());
try {
await repository.loadNotes(event.delivery.id);
await repository.loadTemplates();
} catch (e, st) {
debugPrint("Fehler beim Herunterladen der Notizen: $e $st");
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
return;
}
opBloc.add(FailOperation(message: "Notizen konnten nicht heruntergeladen werden."));
emit.call(NoteLoadingFailed());
}
}
Future<void> _add(AddNote event, Emitter<NoteState> emit) async {
opBloc.add(StartOperation());
try {
await repository.addNote(event.deliveryId, event.note);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen der Notiz: $e $st");
_handleError(e, "Fehler beim Hinzufügen der Notiz");
}
}
Future<void> _edit(EditNote event, Emitter<NoteState> emit) async {
opBloc.add(StartOperation());
try {
await repository.editNote(event.noteId, event.content);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Editieren der Notiz: $e $st");
_handleError(e, "Fehler beim Editieren der Notiz");
}
}
Future<void> _remove(RemoveNote event, Emitter<NoteState> emit) async {
opBloc.add(StartOperation());
try {
await repository.deleteNote(event.noteId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Löschen der Notiz: $e $st");
_handleError(e, "Notiz konnte nicht gelöscht werden");
}
}
}

View File

@ -1,75 +0,0 @@
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:image_picker/image_picker.dart';
abstract class NoteEvent {}
class LoadNote extends NoteEvent {
LoadNote({required this.delivery});
final Delivery delivery;
}
class ResetNotes extends NoteEvent {}
class AddNote extends NoteEvent {
AddNote({required this.note, required this.deliveryId});
final String note;
final String deliveryId;
}
class AddNoteOffline extends NoteEvent {
AddNoteOffline({required this.note, required this.deliveryId, required this.noteId});
final String note;
final String noteId;
final String deliveryId;
}
class RemoveNote extends NoteEvent {
RemoveNote({required this.noteId});
final String noteId;
}
class EditNote extends NoteEvent {
EditNote({required this.content, required this.noteId});
final String noteId;
final String content;
}
class AddImageNote extends NoteEvent {
AddImageNote({required this.file, required this.deliveryId});
final XFile file;
final String deliveryId;
}
class RemoveImageNote extends NoteEvent {
RemoveImageNote({required this.objectId, required this.deliveryId});
final String objectId;
final String deliveryId;
}
class NotesUpdated extends NoteEvent {
final List<Note> notes;
NotesUpdated({required this.notes});
}
class ImageUpdated extends NoteEvent {
final List<ImageNote> images;
ImageUpdated({required this.images});
}
class DataUpdated extends NoteEvent {
final List<ImageNote> images;
final List<Note> notes;
final List<NoteTemplate> templates;
DataUpdated({required this.images, required this.notes, required this.templates});
}

View File

@ -1,41 +0,0 @@
import 'package:hl_lieferservice/model/delivery.dart';
abstract class NoteState {}
class NoteInitial extends NoteState {}
class NoteLoading extends NoteState {}
class NoteLoadingFailed extends NoteState {}
class NoteLoadedBase extends NoteState {
NoteLoadedBase({
required this.notes,
});
List<Note> notes;
}
class NoteLoaded extends NoteLoadedBase {
NoteLoaded({
this.templates,
this.images,
required super.notes,
});
List<NoteTemplate>? templates;
List<ImageNote>? images;
NoteLoaded copyWith({
List<Note>? notes,
List<NoteTemplate>? templates,
List<ImageNote>? images,
}) {
return NoteLoaded(
notes: notes ?? this.notes,
templates: templates ?? this.templates,
images: images ?? this.images,
);
}
}

View File

@ -0,0 +1,46 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'workflow_event.dart';
import 'workflow_state.dart';
/// Bloc, der den Detail-Workflow einer einzelnen Lieferung trägt.
///
/// Pro `DeliveryDetail`-Page-Push neu instanziert (per `BlocProvider` direkt
/// in der Page), Lifetime endet beim Pop. Strukturelle Persistenzen
/// (Notizen speichern, Artikel entfernen, Complete) gehen NICHT durch
/// diesen Bloc, sondern direkt am globalen `TourBloc` — der Workflow-Bloc
/// trägt nur Step-State + lokale Drafts.
class DeliveryWorkflowBloc
extends Bloc<DeliveryWorkflowEvent, DeliveryWorkflowState> {
DeliveryWorkflowBloc({required String deliveryId})
: super(DeliveryWorkflowState.initial(deliveryId)) {
on<WorkflowGoToStep>((e, emit) => emit(state.copyWith(step: e.step)));
on<WorkflowNextStep>((e, emit) {
final next = state.step.index + 1;
if (next < WorkflowStep.values.length) {
emit(state.copyWith(step: WorkflowStep.values[next]));
}
});
on<WorkflowPreviousStep>((e, emit) {
final prev = state.step.index - 1;
if (prev >= 0) {
emit(state.copyWith(step: WorkflowStep.values[prev]));
}
});
on<WorkflowAddPendingImage>((e, emit) {
final next = [
...state.pendingImageNotes,
PendingImageNote(file: e.file, pickedAt: DateTime.now()),
];
emit(state.copyWith(pendingImageNotes: next));
});
on<WorkflowRemovePendingImage>((e, emit) {
if (e.index < 0 || e.index >= state.pendingImageNotes.length) return;
final next = [...state.pendingImageNotes]..removeAt(e.index);
emit(state.copyWith(pendingImageNotes: next));
});
on<WorkflowOverridePaymentMethod>((e, emit) => emit(
state.copyWith(paymentMethodOverrideId: e.paymentMethodId),
));
}
}

View File

@ -0,0 +1,43 @@
import 'package:image_picker/image_picker.dart';
import 'workflow_state.dart';
/// Events des Detail-Workflow-Blocs. Strukturelle Aktionen (Notiz speichern,
/// Artikel entfernen, Lieferung abschließen) dispatchen NICHT hier, sondern
/// am `TourBloc` — der Workflow-Bloc kümmert sich nur um Step-Navigation
/// und Drafts.
sealed class DeliveryWorkflowEvent {
const DeliveryWorkflowEvent();
}
class WorkflowGoToStep extends DeliveryWorkflowEvent {
const WorkflowGoToStep(this.step);
final WorkflowStep step;
}
class WorkflowNextStep extends DeliveryWorkflowEvent {
const WorkflowNextStep();
}
class WorkflowPreviousStep extends DeliveryWorkflowEvent {
const WorkflowPreviousStep();
}
// ─── Bild-Notiz-Drafts ──────────────────────────────────────────────────
class WorkflowAddPendingImage extends DeliveryWorkflowEvent {
const WorkflowAddPendingImage(this.file);
final XFile file;
}
class WorkflowRemovePendingImage extends DeliveryWorkflowEvent {
const WorkflowRemovePendingImage(this.index);
final int index;
}
// ─── Payment-Auswahl ────────────────────────────────────────────────────
class WorkflowOverridePaymentMethod extends DeliveryWorkflowEvent {
const WorkflowOverridePaymentMethod({required this.paymentMethodId});
final String? paymentMethodId;
}

View File

@ -0,0 +1,87 @@
import 'package:image_picker/image_picker.dart';
/// Die 5 Steps der Auslieferungs-Detail-Page. Reihenfolge ≙ Index.
enum WorkflowStep {
info,
notes,
articles,
services,
summary,
}
extension WorkflowStepX on WorkflowStep {
String get displayName => switch (this) {
WorkflowStep.info => 'Info',
WorkflowStep.notes => 'Notizen',
WorkflowStep.articles => 'Artikel & Gutschriften',
WorkflowStep.services => 'Services',
WorkflowStep.summary => 'Übersicht',
};
/// Kurze Bezeichnung für den Header-Step (Platz ist eng auf Mobilgeräten).
String get shortName => switch (this) {
WorkflowStep.info => 'Info',
WorkflowStep.notes => 'Notizen',
WorkflowStep.articles => 'Artikel',
WorkflowStep.services => 'Services',
WorkflowStep.summary => 'Übersicht',
};
}
/// Eine im Workflow geparkte Bild-Notiz, die noch nicht hochgeladen werden
/// kann — wartet auf den Foto-Upload-Endpoint.
class PendingImageNote {
const PendingImageNote({required this.file, required this.pickedAt});
/// Das vom `image_picker` zurückgegebene File-Handle.
final XFile file;
final DateTime pickedAt;
}
/// State des Detail-Workflows. Ein State, ein Bloc — der Step-Wechsel,
/// die Drafts und die Payment-Auswahl liegen alle hier. So sieht jede
/// Step-Page denselben kohärenten Zustand und ein Step kann Daten aus
/// einem anderen lesen (z. B. Summary liest Article-Drafts).
class DeliveryWorkflowState {
const DeliveryWorkflowState({
required this.deliveryId,
required this.step,
required this.pendingImageNotes,
required this.paymentMethodOverrideId,
});
factory DeliveryWorkflowState.initial(String deliveryId) =>
DeliveryWorkflowState(
deliveryId: deliveryId,
step: WorkflowStep.info,
pendingImageNotes: const [],
paymentMethodOverrideId: null,
);
final String deliveryId;
final WorkflowStep step;
/// Lokal gehaltene Bild-Notizen — solange kein Upload-Endpoint da ist.
final List<PendingImageNote> pendingImageNotes;
/// Wenn der Fahrer im Summary die Zahlungsmethode überschreibt, landet
/// die neue Id hier. `null` = Methode der Lieferung bleibt.
final String? paymentMethodOverrideId;
DeliveryWorkflowState copyWith({
WorkflowStep? step,
List<PendingImageNote>? pendingImageNotes,
Object? paymentMethodOverrideId = _sentinel,
}) {
return DeliveryWorkflowState(
deliveryId: deliveryId,
step: step ?? this.step,
pendingImageNotes: pendingImageNotes ?? this.pendingImageNotes,
paymentMethodOverrideId: identical(paymentMethodOverrideId, _sentinel)
? this.paymentMethodOverrideId
: paymentMethodOverrideId as String?,
);
}
}
const Object _sentinel = Object();

View File

@ -1 +0,0 @@
class NoteImageAddException implements Exception {}

View File

@ -1,10 +0,0 @@
import 'package:hl_lieferservice/model/article.dart';
import 'package:hl_lieferservice/model/delivery.dart';
class NoteInformation {
NoteInformation({required this.note, this.article});
Note note;
Article? article;
}

View File

@ -1,34 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/article/article_list_item.dart';
import 'package:hl_lieferservice/model/article.dart';
class ArticleList extends StatefulWidget {
const ArticleList({
super.key,
required this.articles,
required this.deliveryId,
});
final List<Article> articles;
final String deliveryId;
@override
State<StatefulWidget> createState() => _ArticleListState();
}
class _ArticleListState extends State<ArticleList> {
@override
Widget build(BuildContext context) {
return ListView.separated(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemBuilder:
(context, index) => ArticleListItem(
article: widget.articles[index],
deliveryId: widget.deliveryId,
),
separatorBuilder: (context, index) => const Divider(height: 0),
itemCount: widget.articles.length,
);
}
}

View File

@ -1,97 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/article/article_reset_scan_dialog.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/article/article_unscan_dialog.dart';
import 'package:hl_lieferservice/model/article.dart';
class ArticleListItem extends StatefulWidget {
const ArticleListItem({
super.key,
required this.article,
required this.deliveryId,
});
final Article article;
final String deliveryId;
@override
State<StatefulWidget> createState() => _ArticleListItem();
}
class _ArticleListItem extends State<ArticleListItem> {
Widget _leading() {
int amount = widget.article.getScannedAmount();
Color? color;
Color? textColor;
if (!widget.article.scannable) {
amount = widget.article.amount;
}
if (amount == 0) {
color = Colors.redAccent;
textColor = Theme.of(context).colorScheme.onSecondary;
}
return CircleAvatar(
backgroundColor: color,
child: Text("${amount}x", style: TextStyle(color: textColor)),
);
}
@override
Widget build(BuildContext context) {
Widget actionButton = IconButton.outlined(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(Colors.redAccent),
),
onPressed: () {
showDialog(
context: context,
builder:
(context) => ArticleUnscanDialog(
article: widget.article,
deliveryId: widget.deliveryId,
),
);
},
icon: Icon(
Icons.delete,
color: Theme.of(context).colorScheme.onSecondary,
),
);
if ((widget.article.unscanned() && widget.article.scannable) ||
!widget.article.scannable && widget.article.amount == 0) {
actionButton = IconButton.outlined(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(Colors.blueAccent),
),
onPressed: () {
showDialog(
context: context,
builder:
(context) => ResetArticleAmountDialog(
article: widget.article,
deliveryId: widget.deliveryId,
),
);
},
icon: Icon(
Icons.refresh,
color: Theme.of(context).colorScheme.onSecondary,
),
);
}
return Padding(
padding: const EdgeInsets.all(0),
child: ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainerLowest,
title: Text(widget.article.name),
leading: _leading(),
subtitle: Text("Artikelnr. ${widget.article.articleNumber}"),
trailing: actionButton,
),
);
}
}

View File

@ -1,119 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import '../../../../../model/article.dart';
class ResetArticleAmountDialog extends StatefulWidget {
const ResetArticleAmountDialog({
super.key,
required this.article,
required this.deliveryId,
});
final Article article;
final String deliveryId;
@override
State<StatefulWidget> createState() => _ResetArticleAmountDialogState();
}
class _ResetArticleAmountDialogState extends State<ResetArticleAmountDialog> {
int _selectedAmount = 1;
void _reset() {
String deliveryId = widget.deliveryId;
String articleId = widget.article.internalId.toString();
if (widget.article.scannable) {
context.read<TourBloc>().add(
ResetScanAmountEvent(
articleId: widget.article.internalId.toString(),
deliveryId: widget.deliveryId,
),
);
} else {
debugPrint("ID: $articleId");
debugPrint("AMOUNT :$_selectedAmount");
context.read<TourBloc>().add(
SetArticleAmountEvent(
deliveryId: deliveryId,
articleId: articleId,
amount: _selectedAmount,
),
);
}
Navigator.pop(context);
}
Widget _amountSelection() {
final list = List.generate(3, (index) => index + 1);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Anzahl:", style: Theme.of(context).textTheme.labelLarge),
Padding(
padding: const EdgeInsets.all(10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children:
list
.map(
(index) => ChoiceChip(
label: Text("$index"),
selected: _selectedAmount == index,
onSelected: (bool selected) {
setState(() {
_selectedAmount = index;
});
},
),
)
.toList(),
),
),
],
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("Anzahl Artikel zurücksetzen?"),
content: SizedBox(
height: MediaQuery.of(context).size.height * 0.25,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Wollen Sie die entfernten Artikel wieder hinzufügen?"),
!widget.article.scannable ? _amountSelection() : Container(),
Wrap(
spacing: 10,
runSpacing: 8,
alignment: WrapAlignment.spaceEvenly,
children: [
FilledButton(
onPressed: _reset,
child:
widget.article.scannable
? const Text("Zurücksetzen")
: const Text("Hinzufügen"),
),
OutlinedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text("Abbrechen"),
),
],
),
],
),
),
);
}
}

View File

@ -1,179 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import '../../../../../model/article.dart';
class ArticleUnscanDialog extends StatefulWidget {
const ArticleUnscanDialog({
super.key,
required this.article,
required this.deliveryId,
});
final String deliveryId;
final Article article;
@override
State<StatefulWidget> createState() => _ArticleUnscanDialogState();
}
class _ArticleUnscanDialogState extends State<ArticleUnscanDialog> {
late TextEditingController unscanAmountController;
late TextEditingController unscanNoteController;
bool isValidText = false;
final _formKey = GlobalKey<FormState>();
void _unscan() {
int amountToBeDeleted = int.parse(unscanAmountController.text);
String deliveryId = widget.deliveryId;
String articleId = widget.article.internalId.toString();
String reason = unscanNoteController.text;
if (widget.article.scannable) {
context.read<TourBloc>().add(
UnscanArticleEvent(
deliveryId: deliveryId,
articleId: articleId,
newAmount: amountToBeDeleted,
reason: reason,
),
);
} else {
// If the article is not scannable we need to adjust the quantity of the article
// directly.
context.read<TourBloc>().add(
SetArticleAmountEvent(
deliveryId: deliveryId,
articleId: articleId,
amount: widget.article.amount - amountToBeDeleted,
reason: reason
),
);
}
Navigator.pop(context);
}
@override
void initState() {
super.initState();
unscanAmountController = TextEditingController(text: "1");
unscanNoteController = TextEditingController(text: "");
unscanNoteController.addListener(() {
setState(() {
isValidText = _isValid();
});
});
unscanAmountController.addListener(() {
setState(() {
isValidText = _isValid();
});
});
}
@override
void dispose() {
unscanAmountController.dispose();
unscanNoteController.dispose();
super.dispose();
}
bool _isValid() {
return _isAmountValid() && unscanNoteController.text.isNotEmpty;
}
bool _isAmountValid() {
final amount = int.tryParse(unscanAmountController.text);
return amount != null && amount > 0 && amount <= widget.article.amount;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("Scan rückgängig machen"),
content: SizedBox(
width: double.infinity,
height: 350,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text(
"Wollen Sie den Scanvorgang des Artikel '${widget.article.name}' rückgängig machen und den Artikel aus der Bestellung entfernen?",
),
Form(
key: _formKey,
autovalidateMode: AutovalidateMode.always,
child: Column(
children: [
TextFormField(
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
],
validator: (text) {
if (text == null || text.isEmpty) {
return "Geben Sie eine Zahl ein";
}
final amount = int.tryParse(text);
if (amount == null || amount <= 0) {
return "Geben Sie eine gültige Zahl ein";
}
if (amount > widget.article.amount) {
return "Maximal ${widget.article.amount} möglich.";
}
return null;
},
controller: unscanAmountController,
decoration: const InputDecoration(
labelText: "Menge zu löschender Artikel",
),
),
TextFormField(
controller: unscanNoteController,
keyboardType: TextInputType.text,
decoration: const InputDecoration(
labelText: "Grund für die Entfernung",
),
validator: (text) {
if (text == null || text.isEmpty) {
return "Geben Sie einen Grund an.";
}
return null;
},
),
],
),
),
Wrap(
spacing: 10,
runSpacing: 8,
alignment: WrapAlignment.spaceAround,
children: [
FilledButton(
onPressed: isValidText ? _unscan : null,
child: const Text("Entfernen"),
),
OutlinedButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text("Abbrechen"),
),
],
),
],
),
),
);
}
}

View File

@ -1,173 +1,257 @@
import 'dart:typed_data';
import 'package:easy_stepper/easy_stepper.dart';
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/presentation/delivery_sign.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step.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/model/delivery.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';
class DeliveryDetail extends StatefulWidget {
final String deliveryId;
// ─────────────────────────────────────────────────────────────────────────
// 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
State<StatefulWidget> createState() => _DeliveryDetailState();
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => DeliveryWorkflowBloc(deliveryId: deliveryId),
child: _DeliveryDetailScaffold(deliveryId: deliveryId),
);
}
}
class _DeliveryDetailState extends State<DeliveryDetail> {
late int _step;
late List<EasyStep> _steps;
class _DeliveryDetailScaffold extends StatelessWidget {
const _DeliveryDetailScaffold({required this.deliveryId});
final String deliveryId;
@override
void initState() {
super.initState();
// Reset Note BLOC
// otherwise the notes of the previously
// opened delivery would be loaded
context.read<NoteBloc>().add(ResetNotes());
// Initialize steps
_step = 0;
_steps = [
EasyStep(
icon: const Icon(Icons.info),
customTitle: Text("Info", textAlign: TextAlign.center),
),
EasyStep(
icon: const Icon(Icons.book),
customTitle: Text("Notizen", textAlign: TextAlign.center),
),
EasyStep(
icon: const Icon(Icons.shopping_cart),
customTitle: Text("Artikel/Gutschriften", textAlign: TextAlign.center),
),
EasyStep(
icon: const Icon(Icons.settings),
customTitle: Text("Optionen", textAlign: TextAlign.center),
),
EasyStep(
icon: const Icon(Icons.check_box),
customTitle: Text(
"Überprüfen",
textAlign: TextAlign.center,
overflow: TextOverflow.clip,
),
),
];
}
Widget _stepInfo() {
return DecoratedBox(
decoration: const BoxDecoration(),
child: SizedBox(
height: 115,
child: EasyStepper(
activeStep: _step,
showLoadingAnimation: false,
activeStepTextColor: Theme.of(context).primaryColor,
activeStepBorderType: BorderType.dotted,
finishedStepBorderType: BorderType.normal,
unreachedStepBorderType: BorderType.normal,
activeStepBackgroundColor: Colors.white,
borderThickness: 2,
internalPadding: 0.0,
enableStepTapping: true,
stepRadius: 25.0,
onStepReached:
(index) => {
setState(() {
_step = index;
}),
},
steps: _steps,
),
),
);
}
Widget _stepMissingWarning() {
return Center(
child: Text("Kein Inhalt für den aktuellen Step $_step gefunden."),
);
}
void _clickForward() {
if (_step < _steps.length) {
setState(() {
_step += 1;
});
}
}
void _clickBack() {
if (_step > 0) {
setState(() {
_step -= 1;
});
}
}
void _openSignatureView(Delivery delivery) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) {
return BlocProvider.value(
value: context.read<NoteBloc>(),
child: SignatureView(onSigned: _onSign, delivery: delivery),
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocBuilder<TourBloc, TourState>(
builder: (context, tourState) {
if (tourState is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
},
),
);
}
void _onSign(Uint8List customer, Uint8List driver) async {
context.read<TourBloc>().add(
FinishDeliveryEvent(
deliveryId: widget.deliveryId,
customerSignature: customer,
driverSignature: driver,
),
);
Navigator.pop(context);
Navigator.pop(context);
}
Widget _stepsNavigation(Delivery delivery) {
return SafeArea(
top: false,
child: SizedBox(
width: double.infinity,
height: 90,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton(
onPressed: _step == 0 ? null : _clickBack,
child: const Text("zurück"),
}
final details = tourState.details;
final delivery = _findDelivery(details);
if (delivery == null) {
return Scaffold(
appBar: AppBar(title: const Text('Lieferung')),
body: Center(
child: Text('Lieferung $deliveryId nicht in der Tour gefunden.'),
),
Padding(
padding: const EdgeInsets.only(left: 20),
child: FilledButton(
onPressed: () {
if (_step == _steps.length - 1) {
_openSignatureView(delivery);
} else {
_clickForward();
}
},
child:
_step == _steps.length - 1
? const Text("Unterschreiben")
: const Text("weiter"),
);
}
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),
],
),
);
},
);
}
Delivery? _findDelivery(TourDetails details) {
for (final d in details.deliveries) {
if (d.id == deliveryId) return d;
}
return null;
}
}
// ─── 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,
),
),
],
@ -175,37 +259,186 @@ class _DeliveryDetailState extends State<DeliveryDetail> {
),
);
}
}
class _StepConnector extends StatelessWidget {
const _StepConnector({required this.isPassed});
final bool isPassed;
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
Delivery? delivery;
if (state is TourLoaded) {
delivery = state.tour.deliveries.firstWhere(
(d) => d.id == widget.deliveryId,
);
}
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,
),
);
}
}
return Scaffold(
appBar: AppBar(title: const Text("Auslieferungsdetails")),
body: delivery == null
? const Center(child: CircularProgressIndicator())
: Column(
children: [
_stepInfo(),
const Divider(),
Expanded(
child:
StepFactory().make(_step, delivery) ??
_stepMissingWarning(),
),
],
),
bottomNavigationBar:
delivery == null ? null : _stepsNavigation(delivery),
);
// ─── 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'),
),
],
),
);
},
),
);
}
}

View File

@ -1,221 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import '../../bloc/tour_event.dart';
class DeliveryDiscount extends StatefulWidget {
const DeliveryDiscount({
super.key,
this.discount,
required this.disabled,
required this.deliveryId,
});
final bool disabled;
final Discount? discount;
final String deliveryId;
@override
State<StatefulWidget> createState() => _DeliveryDiscountState();
}
class _DeliveryDiscountState extends State<DeliveryDiscount> {
final int stepSize = 10;
late TextEditingController _reasonController;
late bool _isReasonEmpty;
late bool _isUpdated;
late int _discountValue;
@override
void initState() {
super.initState();
_reasonController = TextEditingController(text: widget.discount?.note);
_isReasonEmpty = _reasonController.text.isEmpty;
_reasonController.addListener(() {
setState(() {
_isReasonEmpty = _reasonController.text.isEmpty;
});
});
_discountValue =
widget.discount?.article.getGrossPrice().floor().abs() ?? 0;
_isUpdated = _discountValue > 0 && _reasonController.text.isNotEmpty;
}
@override
void dispose() {
super.dispose();
_reasonController.dispose();
}
bool _maximumReached() {
return _discountValue >= 150;
}
bool _minimumReached() {
return _discountValue <= 0;
}
Widget _incrementDiscount() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton.filled(
onPressed:
_minimumReached() || widget.disabled
? null
: () {
setState(() {
if (_discountValue - stepSize >= 0) {
_discountValue -= stepSize;
}
});
},
icon: const Icon(Icons.remove),
style: ButtonStyle(
backgroundColor:
_minimumReached() || widget.disabled
? WidgetStateProperty.all(Colors.grey)
: WidgetStateProperty.all(Colors.red),
),
),
Padding(
padding: const EdgeInsets.all(5),
child: Column(
children: [
Text(
"${_discountValue.abs()}",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18.0,
),
),
const Text("max. 150€", style: TextStyle(fontSize: 10.0)),
],
),
),
IconButton.filled(
onPressed:
_maximumReached() || widget.disabled
? null
: () {
setState(() {
_discountValue += stepSize;
});
},
icon: const Icon(Icons.add),
style: ButtonStyle(
backgroundColor:
_maximumReached() || widget.disabled
? WidgetStateProperty.all(Colors.grey)
: WidgetStateProperty.all(Colors.green),
),
),
],
);
}
void _resetValues() async {
setState(() {
_discountValue = 0;
_reasonController.clear();
_isUpdated = false;
});
context.read<TourBloc>().add(
RemoveDiscountEvent(deliveryId: widget.deliveryId),
);
}
void _updateValues() async {
if (_isUpdated) {
context.read<TourBloc>().add(
UpdateDiscountEvent(
deliveryId: widget.deliveryId,
value: _discountValue,
reason: _reasonController.text,
),
);
} else {
context.read<TourBloc>().add(
AddDiscountEvent(
deliveryId: widget.deliveryId,
value: _discountValue,
reason: _reasonController.text,
),
);
setState(() {
setState(() {
_isUpdated = true;
});
});
}
}
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSecondary,
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Form(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Betrag:",
style: TextStyle(fontWeight: FontWeight.bold),
),
_incrementDiscount(),
const Padding(
padding: EdgeInsets.only(top: 10),
child: Text(
"Begründung:",
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: TextFormField(
controller: _reasonController,
validator: (text) {
if (text == null || text.isEmpty) {
return "Begründung für Gutschrift notwendig.";
}
return null;
},
),
),
Wrap(
spacing: 10,
runSpacing: 8,
children: [
FilledButton(
onPressed:
!_isReasonEmpty && _discountValue > 0
? _updateValues
: null,
child: const Text("Speichern"),
),
OutlinedButton(
onPressed: _discountValue > 0 && widget.discount != null ? _resetValues : null,
child: const Text("Gutschrift entfernen"),
),
],
),
],
),
),
),
);
}
}

View File

@ -1,121 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/model/delivery.dart' as model;
import '../../bloc/tour_event.dart';
class DeliveryOptionsView extends StatefulWidget {
const DeliveryOptionsView({
super.key,
required this.options,
required this.deliveryId,
});
final List<model.DeliveryOption> options;
final String deliveryId;
@override
State<StatefulWidget> createState() => _DeliveryOptionsViewState();
}
class _DeliveryOptionsViewState extends State<DeliveryOptionsView> {
late Map<String, TextEditingController> _controllers;
@override
void initState() {
super.initState();
_controllers = {};
for (final option in widget.options.where((option) => option.numerical)) {
_controllers[option.key] = TextEditingController(text: option.getValue().toString());
}
}
@override
void didUpdateWidget(covariant DeliveryOptionsView oldWidget) {
super.didUpdateWidget(oldWidget);
}
void _update(model.DeliveryOption option, dynamic value) {
if (value is bool) {
context.read<TourBloc>().add(
UpdateDeliveryOptionEvent(
key: option.key,
value: !value,
deliveryId: widget.deliveryId,
),
);
return;
}
context.read<TourBloc>().add(
UpdateDeliveryOptionEvent(
key: option.key,
value: value,
deliveryId: widget.deliveryId,
),
);
}
List<Widget> _options() {
List<Widget> boolOptions =
widget.options.where((option) => !option.numerical).map((option) {
return CheckboxListTile(
value: option.getValue(),
onChanged: (value) {
_update(option, option.getValue());
},
title: Text(option.display),
);
}).toList();
List<Widget> numericalOptions =
widget.options.where((option) => option.numerical).map((option) {
return Padding(
padding: const EdgeInsets.all(15),
child: TextFormField(
decoration: InputDecoration(labelText: option.display),
controller: _controllers[option.key],
keyboardType: TextInputType.number,
onTapOutside: (event) {
FocusScope.of(context).unfocus();
_update(option, _controllers[option.key]?.text);
},
textInputAction: TextInputAction.done,
onFieldSubmitted: (value) {
_update(option, value);
},
),
);
}).toList();
return [
Padding(
padding: const EdgeInsets.only(bottom: 5),
child: Text(
"Auswählbare Optionen",
style: Theme.of(context).textTheme.headlineSmall,
),
),
...boolOptions,
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
"Zahlenwerte",
style: Theme.of(context).textTheme.headlineSmall,
),
),
...numericalOptions,
];
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(10),
child: ListView(children: _options()),
);
}
}

View File

@ -1,56 +1,129 @@
import 'dart:typed_data';
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:flutter/material.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:intl/intl.dart';
import 'package:signature/signature.dart';
enum _SigningPhase { customerAcceptance, customerSignature, driverSignature }
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';
/// Daten, die der Abschluss-Flow an den Aufrufer zurückgibt: beide
/// Unterschriften als PNG plus die dokumentierten Bestätigungen des Kunden.
class SignatureResult {
const SignatureResult({
required this.customerSignaturePng,
required this.driverSignaturePng,
required this.receiptConfirmed,
required this.notesAcknowledged,
required this.acknowledgedNoteIds,
required this.paymentCollected,
});
final Uint8List customerSignaturePng;
final Uint8List driverSignaturePng;
final bool receiptConfirmed;
final bool notesAcknowledged;
final List<String> acknowledgedNoteIds;
/// Fahrer hat das Inkasso (Bar/EC) des offenen Betrags bestätigt. `false`,
/// wenn kein Inkasso anfiel (offen == 0 oder „Auf Rechnung").
final bool paymentCollected;
}
/// Mehrstufiger Unterschrift-Flow zum Abschließen einer Lieferung.
///
/// Stufe 0 (Fahrer, optional): nur wenn beim Abschluss ein offener Betrag
/// per Vor-Ort-Inkasso (Bar/EC) zu kassieren ist ([requiresCollection]).
/// Der Fahrer bestätigt, dass der Betrag erhalten/abgerechnet wurde — VOR
/// beiden Unterschriften.
/// Stufe 1 (Kunde): sieht die Anmerkungen zur Lieferung, hakt zwei
/// Bestätigungen ab (Anmerkungen-Kenntnisnahme — nur Pflicht, wenn Notizen
/// vorhanden; Empfangsbestätigung — immer Pflicht) und unterschreibt.
/// Stufe 2 (Fahrer): unterschreibt.
///
/// Erst nach beiden Unterschriften ruft die View [onSigned] mit dem
/// vollständigen [SignatureResult] auf — der Aufrufer triggert dann den
/// Backend-Abschluss und schließt die Seite.
class SignatureView extends StatefulWidget {
const SignatureView({
super.key,
required this.onSigned,
required this.delivery,
required this.details,
required this.onSigned,
this.requiresCollection = false,
this.openAmount = 0,
this.paymentMethodLabel = '',
});
final Delivery delivery;
final TourDetails details;
final void Function(SignatureResult result) onSigned;
/// Callback that is called when the user has signed.
/// The parameter stores the path to the image file of the signature.
final void Function(
Uint8List customerSignaturePng,
Uint8List driverSignaturePng,
)
onSigned;
/// Offener Betrag muss vor Ort kassiert werden (offen > 0 UND Bar/EC).
/// Schaltet Stufe 0 (Inkasso-Bestätigung) frei.
final bool requiresCollection;
/// Offener Betrag in Euro (nur für die Anzeige in Stufe 0).
final double openAmount;
/// Anzeigename der Zahlungsmethode (z. B. „Bar", „EC-Karte").
final String paymentMethodLabel;
@override
State<StatefulWidget> createState() => _SignatureViewState();
State<SignatureView> createState() => _SignatureViewState();
}
/// Stufen des Abschluss-Flows.
enum _SignStage { payment, customer, driver }
class _SignatureViewState extends State<SignatureView> {
static const String _receiptText =
'Ich bestätige, dass ich die Ware im ordnungsgemäßen Zustand erhalten '
'habe und, dass die Aufstell- und Einbauarbeiten korrekt durchgeführt '
'wurden.';
static const String _notesText =
'Ich nehme die oben genannten Anmerkungen zur Lieferung zur Kenntnis.';
final SignatureController _customerController = SignatureController(
penStrokeWidth: 5,
penStrokeWidth: 3,
penColor: Colors.black,
exportBackgroundColor: Colors.white,
);
final SignatureController _driverController = SignatureController(
penStrokeWidth: 5,
penStrokeWidth: 3,
penColor: Colors.black,
exportBackgroundColor: Colors.white,
);
_SigningPhase _phase = _SigningPhase.customerAcceptance;
late final List<DeliveryNote> _notes;
late _SignStage _stage;
bool _paymentConfirmed = false;
bool _receiptAccepted = false;
bool _notesAccepted = false;
bool _customerEmpty = true;
bool _driverEmpty = true;
bool get _notesEmpty => _notes.isEmpty;
@override
void initState() {
super.initState();
context.read<NoteBloc>().add(LoadNote(delivery: widget.delivery));
// Inkasso-Bestätigung (Stufe 0) nur wenn gefordert, sonst direkt zum Kunden.
_stage =
widget.requiresCollection ? _SignStage.payment : _SignStage.customer;
_notes = widget.details.notesByDeliveryId[widget.delivery.id] ??
const <DeliveryNote>[];
_customerController.addListener(() {
if (_customerEmpty != _customerController.isEmpty) {
setState(() => _customerEmpty = _customerController.isEmpty);
}
});
_driverController.addListener(() {
if (_driverEmpty != _driverController.isEmpty) {
setState(() => _driverEmpty = _driverController.isEmpty);
}
});
}
@override
@ -60,314 +133,333 @@ class _SignatureViewState extends State<SignatureView> {
super.dispose();
}
void _onAcceptanceDone() {
setState(() => _phase = _SigningPhase.customerSignature);
}
bool get _customerStepValid =>
_receiptAccepted && (_notesAccepted || _notesEmpty) && !_customerEmpty;
void _onCustomerSigned() {
setState(() => _phase = _SigningPhase.driverSignature);
}
Future<void> _onDriverSigned() async {
widget.onSigned(
(await _customerController.toPngBytes())!,
(await _driverController.toPngBytes())!,
);
}
@override
Widget build(BuildContext context) {
return switch (_phase) {
_SigningPhase.customerAcceptance => _AcceptanceStep(
onContinue: _onAcceptanceDone,
),
_SigningPhase.customerSignature => _SignaturePadStep(
controller: _customerController,
delivery: widget.delivery,
appBarTitle: "Unterschrift des Kunden",
buttonLabel: "Weiter",
onContinue: _onCustomerSigned,
),
_SigningPhase.driverSignature => _SignaturePadStep(
controller: _driverController,
delivery: widget.delivery,
appBarTitle: "Unterschrift des Fahrers",
buttonLabel: "Absenden",
onContinue: _onDriverSigned,
),
};
}
}
class _AcceptanceStep extends StatefulWidget {
const _AcceptanceStep({required this.onContinue});
final VoidCallback onContinue;
@override
State<_AcceptanceStep> createState() => _AcceptanceStepState();
}
class _AcceptanceStepState extends State<_AcceptanceStep> {
bool _customerAccepted = false;
bool _noteAccepted = false;
Widget _notesContent(NoteState noteState) {
if (noteState is! NoteLoaded) {
return const SizedBox(
width: double.infinity,
child: Center(child: CircularProgressIndicator()),
);
/// Ist der Primär-Button auf der aktuellen Stufe aktiv?
bool get _stageValid {
switch (_stage) {
case _SignStage.payment:
return _paymentConfirmed;
case _SignStage.customer:
return _customerStepValid;
case _SignStage.driver:
return !_driverEmpty;
}
if (noteState.notes.isEmpty) {
return const SizedBox(
width: double.infinity,
child: Center(child: Text("Keine Notizen vorhanden")),
);
}
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return ListTile(
leading: const Icon(Icons.event_note_outlined),
title: Text(noteState.notes[index].content),
contentPadding: const EdgeInsets.all(20),
tileColor: Theme.of(context).colorScheme.onSecondary,
);
},
separatorBuilder: (context, index) => const Divider(height: 0),
itemCount: noteState.notes.length,
);
}
Widget _notes(NoteState noteState) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 15),
child: Text(
"Notizen",
style: Theme.of(context).textTheme.headlineSmall,
),
),
_notesContent(noteState),
const Padding(padding: EdgeInsets.only(top: 25), child: Divider()),
],
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<NoteBloc, NoteState>(
builder: (context, noteState) {
final notesEmpty = switch (noteState) {
NoteLoadedBase(notes: final ns) => ns.isEmpty,
_ => true,
};
final isButtonEnabled =
_customerAccepted && (_noteAccepted || notesEmpty);
return Scaffold(
appBar: AppBar(title: const Text("Unterschrift des Kunden")),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: ListView(
children: [
Padding(
padding: const EdgeInsets.only(top: 25, bottom: 0),
child: _notes(noteState),
),
Padding(
padding: const EdgeInsets.only(top: 25.0, bottom: 0),
child: Row(
children: [
Checkbox(
value: _noteAccepted,
onChanged: notesEmpty
? null
: (value) {
setState(() {
_noteAccepted = value!;
});
},
),
Flexible(
child: InkWell(
onTap: notesEmpty
? null
: () {
setState(() {
_noteAccepted = !_noteAccepted;
});
},
child: Text(
"Ich nehme die oben genannten Anmerkungen zur Lieferung zur Kenntnis.",
overflow: TextOverflow.fade,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.only(top: 25.0, bottom: 10.0),
child: Row(
children: [
Checkbox(
value: _customerAccepted,
onChanged: (value) {
setState(() {
_customerAccepted = value!;
});
},
),
Flexible(
child: InkWell(
child: Text(
"Ware in ordnungsgemäßem Zustand erhalten. Aufstell- und Einbauarbeiten wurden korrekt durchgeführt",
overflow: TextOverflow.fade,
),
onTap: () {
setState(() {
_customerAccepted = !_customerAccepted;
});
},
),
),
],
),
),
],
),
),
bottomNavigationBar: SafeArea(
top: false,
child: SizedBox(
width: double.infinity,
height: 90,
child: Center(
child: FilledButton(
onPressed: isButtonEnabled ? widget.onContinue : null,
child: const Text("Unterschreiben"),
),
),
),
Future<void> _onPrimaryPressed() async {
switch (_stage) {
case _SignStage.payment:
setState(() => _stage = _SignStage.customer);
return;
case _SignStage.customer:
setState(() => _stage = _SignStage.driver);
return;
case _SignStage.driver:
final customerPng = await _customerController.toPngBytes();
final driverPng = await _driverController.toPngBytes();
if (customerPng == null || driverPng == null) return;
widget.onSigned(
SignatureResult(
customerSignaturePng: customerPng,
driverSignaturePng: driverPng,
receiptConfirmed: _receiptAccepted,
notesAcknowledged: _notesEmpty ? false : _notesAccepted,
acknowledgedNoteIds:
_notesEmpty ? const [] : _notes.map((n) => n.id).toList(),
paymentCollected: widget.requiresCollection && _paymentConfirmed,
),
);
},
);
}
}
class _SignaturePadStep extends StatefulWidget {
const _SignaturePadStep({
required this.controller,
required this.delivery,
required this.appBarTitle,
required this.buttonLabel,
required this.onContinue,
});
final SignatureController controller;
final Delivery delivery;
final String appBarTitle;
final String buttonLabel;
final VoidCallback onContinue;
@override
State<_SignaturePadStep> createState() => _SignaturePadStepState();
}
class _SignaturePadStepState extends State<_SignaturePadStep> {
bool _isEmpty = true;
late final VoidCallback _listener;
@override
void initState() {
super.initState();
_isEmpty = widget.controller.isEmpty;
_listener = () {
if (_isEmpty != widget.controller.isEmpty) {
setState(() {
_isEmpty = widget.controller.isEmpty;
});
}
};
widget.controller.addListener(_listener);
}
}
@override
void dispose() {
widget.controller.removeListener(_listener);
super.dispose();
String get _paymentText {
final amount = widget.openAmount.toStringAsFixed(2).replaceAll('.', ',');
final via = widget.paymentMethodLabel.isEmpty
? ''
: ' per ${widget.paymentMethodLabel}';
return 'Ich bestätige, dass der offene Betrag von $amount$via '
'erhalten bzw. abgerechnet wurde.';
}
@override
Widget build(BuildContext context) {
final formattedDate = DateFormat("dd.MM.yyyy").format(DateTime.now());
final theme = Theme.of(context);
final customer = widget.details.customerOf(widget.delivery);
final date = DateFormat('dd.MM.yyyy').format(DateTime.now());
final isPayment = _stage == _SignStage.payment;
final isDriver = _stage == _SignStage.driver;
final String title;
switch (_stage) {
case _SignStage.payment:
title = 'Zahlung bestätigen';
case _SignStage.customer:
title = 'Unterschrift des Kunden';
case _SignStage.driver:
title = 'Unterschrift des Fahrers';
}
return Scaffold(
appBar: AppBar(title: Text(widget.appBarTitle)),
body: Padding(
padding: const EdgeInsets.all(20.0),
appBar: AppBar(title: Text(title)),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
SizedBox(
width: double.infinity,
height: MediaQuery.of(context).size.height * 0.75,
child: DecoratedBox(
decoration: const BoxDecoration(color: Colors.white),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"Lieferung an: ${widget.delivery.customer.name}",
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
Expanded(
child: Signature(
controller: widget.controller,
backgroundColor: Colors.white,
),
),
],
),
),
const Divider(),
Text(
"${widget.delivery.customer.address.city}, den $formattedDate",
),
],
if (isPayment) ...[
// Stufe 0 — Fahrer kassiert (Bar/EC) und bestätigt VOR den
// Unterschriften.
_PaymentDueCard(
openAmount: widget.openAmount,
methodLabel: widget.paymentMethodLabel,
),
const SizedBox(height: 16),
_ConfirmTile(
value: _paymentConfirmed,
enabled: true,
label: _paymentText,
onChanged: (v) => setState(() => _paymentConfirmed = v),
),
] else ...[
if (!isDriver) ...[
_NotesSection(notes: _notes),
const SizedBox(height: 16),
_ConfirmTile(
value: _notesAccepted,
enabled: !_notesEmpty,
label: _notesText,
onChanged: (v) => setState(() => _notesAccepted = v),
),
_ConfirmTile(
value: _receiptAccepted,
enabled: true,
label: _receiptText,
onChanged: (v) => setState(() => _receiptAccepted = v),
),
const SizedBox(height: 16),
],
Text(
'Lieferung an: ${customer?.name ?? '⟨Unbekannter Kunde⟩'}',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
_SignaturePad(
controller: isDriver ? _driverController : _customerController,
onClear: () =>
(isDriver ? _driverController : _customerController)
.clear(),
),
const SizedBox(height: 4),
Text(
'${customer?.address.city ?? ''}, den $date',
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _stageValid ? _onPrimaryPressed : null,
icon: Icon(isDriver ? Icons.check : Icons.arrow_forward),
label: Text(isDriver ? 'Abschließen' : 'Weiter'),
),
],
),
),
);
}
}
// ─── Inkasso-Hinweis (Stufe 0) ──────────────────────────────────────────────
class _PaymentDueCard extends StatelessWidget {
const _PaymentDueCard({required this.openAmount, required this.methodLabel});
final double openAmount;
final String methodLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final amount = openAmount.toStringAsFixed(2).replaceAll('.', ',');
return Card(
margin: EdgeInsets.zero,
color: theme.colorScheme.primary.withValues(alpha: 0.07),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Offener Betrag',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.payments_outlined, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Text(
'$amount',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
color: theme.colorScheme.primary,
),
),
const Spacer(),
if (methodLabel.isNotEmpty)
Chip(
label: Text(methodLabel),
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 8),
Text(
'Bitte den Betrag bar entgegennehmen oder über das EC-Gerät '
'abrechnen und anschließend bestätigen.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
bottomNavigationBar: SafeArea(
top: false,
child: SizedBox(
width: double.infinity,
height: 90,
child: Center(
child: FilledButton(
onPressed: _isEmpty ? null : widget.onContinue,
child: Text(widget.buttonLabel),
),
),
),
),
);
}
}
// ─── Notizen-Block ────────────────────────────────────────────────────────
class _NotesSection extends StatelessWidget {
const _NotesSection({required this.notes});
final List<DeliveryNote> notes;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Anmerkungen zur Lieferung',
style:
theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
if (notes.isEmpty)
Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Keine Anmerkungen vorhanden.',
style: TextStyle(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
)
else
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (int i = 0; i < notes.length; i++) ...[
ListTile(
leading: Icon(
notes[i].imageAttachment != null
? (notes[i].imageAttachmentDeleted
? Icons.picture_as_pdf_outlined
: Icons.photo_outlined)
: Icons.event_note_outlined,
color: theme.colorScheme.primary,
),
title: Text(
notes[i].text?.trim().isNotEmpty == true
? notes[i].text!.trim()
: (notes[i].imageAttachmentDeleted
? 'Bild im Lieferbericht enthalten'
: 'Foto-Anhang'),
),
),
if (i < notes.length - 1)
const Divider(height: 1, indent: 16, endIndent: 16),
],
],
),
),
],
);
}
}
// ─── Bestätigungs-Checkbox ──────────────────────────────────────────────────
class _ConfirmTile extends StatelessWidget {
const _ConfirmTile({
required this.value,
required this.enabled,
required this.label,
required this.onChanged,
});
final bool value;
final bool enabled;
final String label;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return CheckboxListTile(
value: value,
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
title: Text(label, style: const TextStyle(fontSize: 14)),
);
}
}
// ─── Signatur-Pad ───────────────────────────────────────────────────────────
class _SignaturePad extends StatelessWidget {
const _SignaturePad({required this.controller, required this.onClear});
final SignatureController controller;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
height: 220,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.antiAlias,
child: Signature(
controller: controller,
backgroundColor: Colors.white,
),
),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: onClear,
icon: const Icon(Icons.undo),
label: const Text('Löschen'),
),
),
],
);
}
}

View File

@ -1,177 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/model/delivery.dart';
import '../../../../model/tour.dart';
class DeliverySummary extends StatefulWidget {
const DeliverySummary({super.key, required this.delivery});
final Delivery delivery;
@override
State<StatefulWidget> createState() => _DeliverySummaryState();
}
class _DeliverySummaryState extends State<DeliverySummary> {
late List<Payment> _paymentMethods;
@override
void initState() {
super.initState();
final tourState = context.read<TourBloc>().state as TourLoaded;
_paymentMethods = [...tourState.paymentOptions];
if (!_paymentMethods.any(
(payment) => payment.id == widget.delivery.payment.id,
)) {
_paymentMethods.add(widget.delivery.payment);
}
}
Widget _deliveredArticles() {
List<Widget> items =
widget.delivery
.getDeliveredArticles()
.map(
(article) => DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSecondary,
),
child: ListTile(
title: Text(article.name),
subtitle: Text("Artikelnr. ${article.articleNumber}"),
trailing: Text(
"${article.scannable ? article.getGrossPriceScanned().toStringAsFixed(2) : article.getGrossPrice().toStringAsFixed(2)}",
),
leading: CircleAvatar(
child: Text(
"${article.scannable ? article.scannedAmount : article.amount}x",
),
),
),
),
)
.toList();
items.add(
DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSecondary,
),
child: ListTile(
title: const Text(
"Gesamtsumme:",
style: TextStyle(fontWeight: FontWeight.bold),
),
trailing: Text(
"${widget.delivery.getGrossPrice().toStringAsFixed(2)}",
style: TextStyle(fontWeight: FontWeight.bold),
),
),
),
);
return ListView(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
children: items,
);
}
Widget _paymentOptions() {
List<DropdownMenuEntry> entries =
_paymentMethods
.map(
(payment) => DropdownMenuEntry(
value: payment.id,
label: "${payment.description} (${payment.shortcode})",
),
)
.toList();
return DropdownMenu(
dropdownMenuEntries: entries,
initialSelection: widget.delivery.payment.id,
onSelected: (id) {
context.read<TourBloc>().add(
UpdateSelectedPaymentMethodEvent(
deliveryId: widget.delivery.id,
payment: _paymentMethods.firstWhere((payment) => payment.id == id),
),
);
},
);
}
Widget _paymentDone() {
return DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSecondary,
),
child: Column(
children: [
ListTile(
title: const Text(
"Bei Bestellung bezahlt:",
style: TextStyle(fontWeight: FontWeight.bold),
),
trailing: Text("${widget.delivery.prepayment.toStringAsFixed(2)}"),
),
ListTile(
title: const Text(
"Offener Betrag:",
style: TextStyle(fontWeight: FontWeight.bold),
),
trailing: Text(
"${widget.delivery.getOpenPrice().toStringAsFixed(2)}",
style: TextStyle(fontWeight: FontWeight.w900, color: Colors.red),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final insets = EdgeInsets.all(10);
return Padding(
padding: const EdgeInsets.all(10),
child: ListView(
children: [
Text(
"Ausgelieferte Artikel",
style: Theme.of(context).textTheme.headlineSmall,
),
Padding(padding: insets, child: _deliveredArticles()),
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
"Geleistete Zahlung",
style: Theme.of(context).textTheme.headlineSmall,
),
),
Padding(padding: insets, child: _paymentDone()),
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
"Zahlungsmethode",
style: Theme.of(context).textTheme.headlineSmall,
),
),
Padding(padding: insets, child: _paymentOptions()),
],
),
);
}
}

View File

@ -1,149 +0,0 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.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/model/delivery.dart';
class NoteAddDialog extends StatefulWidget {
final String delivery;
final List<NoteTemplate> templates;
const NoteAddDialog({
super.key,
required this.delivery,
required this.templates,
});
@override
State<StatefulWidget> createState() => _NoteAddDialogState();
}
class _NoteAddDialogState extends State<NoteAddDialog> {
final _noteController = TextEditingController();
final _noteSelectionController = TextEditingController();
late FocusNode _noteFieldFocusNode;
bool _isCustomNotesEmpty = true;
@override
void initState() {
super.initState();
_noteFieldFocusNode = FocusNode();
_noteController.addListener(() {
setState(() {
_isCustomNotesEmpty = _noteController.text.isEmpty;
});
});
}
void _onSave() {
String content = _noteController.text;
context.read<NoteBloc>().add(
AddNote(note: content, deliveryId: widget.delivery),
);
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
return Dialog(
// Default Dialog.insetPadding eats 80 dp horizontally on phones, leaving
// too little room for two side-by-side buttons on narrow devices like
// the Samsung A16F. Shrinking the inset gives back ~64 dp.
insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24),
child: SizedBox(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height * 0.6,
child: Padding(
padding: const EdgeInsets.all(20),
child: ListView(
//mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Notiz hinzufügen",
style: Theme.of(context).textTheme.headlineSmall,
),
IconButton(
onPressed: () {
Navigator.of(context).pop();
},
icon: Icon(Icons.close),
),
],
),
Padding(
padding: const EdgeInsets.only(bottom: 10.0, top: 20),
child: DropdownMenu(
controller: _noteSelectionController,
onSelected: (int? value) {
setState(() {
_noteController.text =
widget.templates[value!].content;
});
},
width: double.infinity,
label: const Text("Notiz auswählen"),
dropdownMenuEntries:
widget.templates
.mapIndexed(
(i, note) =>
DropdownMenuEntry(value: i, label: note.title),
)
.toList(),
),
),
const Padding(
padding: EdgeInsets.only(top: 0.0, bottom: 0.0),
child: Center(child: Text("oder")),
),
Padding(
padding: const EdgeInsets.only(top: 10.0, bottom: 20.0),
child: TextFormField(
onTapOutside: (_) { _noteFieldFocusNode.unfocus(); },
controller: _noteController,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) {_noteFieldFocusNode.unfocus();},
focusNode: _noteFieldFocusNode,
decoration: const InputDecoration(
labelText: "Eigene Notiz",
border: OutlineInputBorder(),
),
minLines: 8,
maxLines: 10,
),
),
Wrap(
spacing: 10,
runSpacing: 8,
children: [
FilledButton(
onPressed:
_noteSelectionController.text.isNotEmpty ||
_noteController.text.isNotEmpty
? _onSave
: null,
child: const Text("Hinzufügen"),
),
OutlinedButton(
onPressed: () {
_noteController.clear();
_noteSelectionController.clear();
},
child: const Text("Zurücksetzen"),
),
],
),
],
),
),
),
);
}
}

View File

@ -1,111 +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/model/delivery.dart';
class NoteEditDialog extends StatefulWidget {
final Note note;
const NoteEditDialog({super.key, required this.note});
@override
State<StatefulWidget> createState() => _NoteEditDialogState();
}
class _NoteEditDialogState extends State<NoteEditDialog> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _editController;
late FocusNode _noteFieldFocusNode;
@override
void initState() {
super.initState();
_noteFieldFocusNode = FocusNode();
_editController = TextEditingController(text: widget.note.content);
}
void _onEdit(BuildContext context) {
context.read<NoteBloc>().add(
EditNote(
content: _editController.text,
noteId: widget.note.id.toString(),
),
);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Dialog(
child: SizedBox(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height * 0.5,
child: Padding(
padding: const EdgeInsets.all(20),
child: ListView(
children: [
Text(
"Notiz bearbeiten",
style: Theme.of(context).textTheme.headlineMedium,
),
Padding(
padding: const EdgeInsets.only(top: 15),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
focusNode: _noteFieldFocusNode,
onTapOutside: (_) {
_noteFieldFocusNode.unfocus();
},
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) {
_noteFieldFocusNode.unfocus();
},
decoration: InputDecoration(label: const Text("Notiz")),
controller: _editController,
minLines: 10,
maxLines: 12,
),
Padding(
padding: const EdgeInsets.only(top: 20),
child: Row(
children: [
FilledButton(
onPressed: () {
_onEdit(context);
},
child: const Text("Bearbeiten"),
),
Padding(
padding: const EdgeInsets.only(left: 10),
child: OutlinedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text("Abbrechen"),
),
),
],
),
),
],
),
),
),
],
),
),
),
);
}
}

View File

@ -1,43 +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/model/delivery.dart';
class NoteLoadingFailPage extends StatelessWidget {
const NoteLoadingFailPage({super.key, required this.delivery});
final Delivery delivery;
void _onRetry(BuildContext context) {
context.read<NoteBloc>().add(LoadNote(delivery: delivery));
}
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(50),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 72, color: Theme.of(context).colorScheme.error,),
Padding(
padding: const EdgeInsets.only(top: 30),
child: Text(
"Leider ist es beim Laden der Notizen zu einem Fehler gekommen.",
),
),
Padding(
padding: const EdgeInsets.only(top: 30),
child: FilledButton(
onPressed: () => _onRetry(context),
child: Text("Erneut versuchen"),
),
),
],
),
),
);
}
}

View File

@ -1,108 +0,0 @@
import 'dart:typed_data';
import 'package:carousel_slider/carousel_slider.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.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/model/delivery.dart';
class NoteImageOverview extends StatefulWidget {
final List<ImageNote> images;
final String deliveryId;
const NoteImageOverview({
super.key,
required this.images,
required this.deliveryId,
});
@override
State<StatefulWidget> createState() => _NoteImageOverviewState();
}
class _NoteImageOverviewState extends State<NoteImageOverview> {
int? _imageDeleting;
void _onRemoveImage(int index) {
ImageNote note = widget.images[index];
context.read<NoteBloc>().add(
RemoveImageNote(objectId: note.objectId, deliveryId: widget.deliveryId),
);
}
Widget _buildImageCarousel() {
return CarouselSlider(
options: CarouselOptions(
height: 300.0,
aspectRatio: 2.0,
enableInfiniteScroll: false,
),
items:
widget.images.mapIndexed((index, data) {
Uint8List bytes = data.data!;
return Builder(
builder: (BuildContext context) {
return Stack(
children: [
Padding(
padding: const EdgeInsets.all(15.0),
child: Image.memory(
bytes,
fit: BoxFit.fill,
width: 1920.0,
height: 1090.0,
),
),
_imageDeleting == index
? Stack(
children: [
Padding(
padding: const EdgeInsets.all(15.0),
child: Container(
color: Colors.black.withValues(alpha: 0.5),
),
),
Center(
child: CircularProgressIndicator(
backgroundColor:
Theme.of(context).colorScheme.onSecondary,
),
),
],
)
: Container(),
Positioned(
right: 0.0,
top: 0.0,
child: CircleAvatar(
radius: 20,
child: IconButton.filled(
onPressed:
!(_imageDeleting == index)
? () {
_onRemoveImage(index);
}
: null,
icon: const Icon(Icons.delete, color: Colors.white),
),
),
),
],
);
},
);
}).toList(),
);
}
@override
Widget build(BuildContext context) {
return widget.images.isEmpty
? const Center(child: Text("Noch keine Bilder hochgeladen"))
: _buildImageCarousel();
}
}

View File

@ -1,30 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/feature/delivery/detail/model/note.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_list_item.dart';
class NoteList extends StatelessWidget {
final List<NoteInformation> notes;
final String deliveryId;
const NoteList({super.key, required this.notes, required this.deliveryId});
@override
Widget build(BuildContext context) {
if (notes.isEmpty) {
return const Center(child: Text("keine Notizen vorhanden"));
}
return ListView.separated(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemBuilder:
(context, index) => NoteListItem(
note: notes[index],
deliveryId: deliveryId,
index: index,
),
separatorBuilder: (context, index) => const Divider(height: 0),
itemCount: notes.length,
);
}
}

View File

@ -1,105 +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/model/note.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_edit_dialog.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
enum NoteItemAction { noteEdit, noteDelete }
class NoteListItem extends StatelessWidget {
final NoteInformation note;
final String deliveryId;
final int index;
const NoteListItem({
super.key,
required this.note,
required this.deliveryId,
required this.index,
});
void _onDelete(BuildContext context) {
context.read<NoteBloc>().add(RemoveNote(noteId: note.note.id.toString()));
}
Widget? _subtitle(BuildContext context) {
String discountArticleId =
(context.read<TourBloc>().state as TourLoaded)
.tour
.discountArticleNumber;
if (note.article != null &&
note.article?.articleNumber == discountArticleId) {
return const Text("Begründung der Gutschrift");
}
return note.article != null ? Text(note.article!.name) : null;
}
void _onEdit(BuildContext context) {
showDialog(
context: context,
builder:
(_) => BlocProvider.value(
value: context.read<NoteBloc>(),
child: NoteEditDialog(note: note.note),
),
);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(0),
child: ListTile(
title: Text(note.note.content),
subtitle: _subtitle(context),
tileColor: Theme.of(context).colorScheme.surfaceContainerLowest,
leading: CircleAvatar(child: Text("${index + 1}")),
trailing: PopupMenuButton<NoteItemAction>(
onSelected: (NoteItemAction action) {
switch (action) {
case NoteItemAction.noteDelete:
_onDelete(context);
break;
case NoteItemAction.noteEdit:
_onEdit(context);
break;
}
},
itemBuilder: (_) {
return [
PopupMenuItem<NoteItemAction>(
value: NoteItemAction.noteEdit,
child: Row(
children: [
Icon(Icons.edit, color: Colors.blueAccent),
Padding(
padding: const EdgeInsets.only(left: 5),
child: const Text("Editieren"),
),
],
),
),
PopupMenuItem<NoteItemAction>(
value: NoteItemAction.noteDelete,
child: Row(
children: [
Icon(Icons.delete, color: Colors.redAccent),
Padding(
padding: const EdgeInsets.only(left: 5),
child: const Text("Löschen"),
),
],
),
),
];
},
),
),
);
}
}

View File

@ -1,181 +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/model/note.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_add_dialog.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_image_overview.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_list.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
import 'package:image_picker/image_picker.dart';
enum NoteAction {
addNote,
addImage
}
class NoteOverview extends StatefulWidget {
final List<NoteInformation> notes;
final List<NoteTemplate> templates;
final List<ImageNote> images;
final String deliveryId;
const NoteOverview({
super.key,
required this.notes,
required this.deliveryId,
required this.templates,
required this.images,
});
@override
State<StatefulWidget> createState() => _NoteOverviewState();
}
class _NoteOverviewState extends State<NoteOverview> {
final _imagePicker = ImagePicker();
Widget _notes() {
for (final note in widget.notes) {
debugPrint("Note: ${note.note.content}");
debugPrint("NOTE Article: ${note.article?.name.toString()}");
}
return NoteList(notes: widget.notes, deliveryId: widget.deliveryId);
}
Widget _images() {
return NoteImageOverview(
images: widget.images,
deliveryId: widget.deliveryId,
);
}
void _onAddNote(BuildContext context) {
showDialog(
context: context,
builder: (_) {
return BlocProvider.value(value: context.read<NoteBloc>(), child: NoteAddDialog(
delivery: widget.deliveryId,
templates: widget.templates,
));
},
);
}
void _onAddImage(BuildContext context) async {
XFile? file = await _imagePicker.pickImage(source: ImageSource.camera);
if (file == null) {
context.read<OperationBloc>().add(
FailOperation(message: "Fehler beim Aufnehmen des Bildes"),
);
return;
}
context.read<NoteBloc>().add(
AddImageNote(file: file, deliveryId: widget.deliveryId),
);
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Padding(
padding: const EdgeInsets.all(10),
child: ListView(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
"Notizen",
style: Theme.of(context).textTheme.headlineMedium,
),
),
_notes(),
Padding(
padding: const EdgeInsets.only(bottom: 10, top: 10),
child: Text(
"Bilder",
style: Theme.of(context).textTheme.headlineMedium,
),
),
_images(),
],
),
),
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 25),
child: PopupMenuButton<NoteAction>(
onSelected: (NoteAction action) {
switch (action) {
case NoteAction.addNote:
_onAddNote(context);
break;
case NoteAction.addImage:
_onAddImage(context);
break;
}
},
itemBuilder: (_) {
return [
PopupMenuItem<NoteAction>(
value: NoteAction.addNote,
child: Row(
children: [
Icon(
Icons.note_add_rounded,
color: Theme.of(context).primaryColor,
),
Padding(
padding: const EdgeInsets.only(left: 10),
child: const Text("Notiz hinzufügen"),
),
],
),
),
PopupMenuItem<NoteAction>(
value: NoteAction.addImage,
child: Row(
children: [
Icon(
Icons.image,
color: Theme.of(context).primaryColor,
),
Padding(
padding: const EdgeInsets.only(left: 10),
child: const Text("Bild hochladen"),
),
],
),
),
];
},
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
Theme.of(context).primaryColor,
),
),
child: CircleAvatar(
radius: 32,
backgroundColor: Theme.of(context).primaryColor,
child: Icon(
Icons.add,
color: Theme.of(context).colorScheme.onSecondary,
),
),
),
),
),
],
);
}
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,716 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/widget/attachment_image.dart';
/// Step 2 — Notizen & Fotos.
///
/// Die UI trennt bewusst in **zwei Sektionen**, weil es zwei
/// unterschiedliche Dinge sind:
/// * **Notizen** (Text): anlegen / bearbeiten / löschen über den
/// `TourBloc` (Backend-Endpoints vorhanden).
/// * **Fotos** (Bild-Notizen): `image_picker` → Upload über
/// `TourBloc.UploadDeliveryNoteImage`. Das Backend schiebt das Bild nach
/// DOCUframe und legt eine Notiz mit der Referenz an. Fotos werden als
/// Thumbnail angezeigt (Tap → formatfüllend) und können nur gelöscht,
/// nicht inline bearbeitet werden.
///
/// Datenmodell: beides sind `DeliveryNote`s. Unterschieden wird über
/// `imageAttachment != null` (Foto) bzw. `text != null` (Notiz).
class StepNotes extends StatelessWidget {
const StepNotes({super.key, required this.delivery, required this.details});
final Delivery delivery;
final TourDetails details;
void _openAddNoteDialog(BuildContext context) {
final tourBloc = context.read<TourBloc>();
showDialog<void>(
context: context,
builder: (_) => _NoteEditorDialog(
title: 'Notiz hinzufügen',
onSubmit: (text) => tourBloc.add(
AddDeliveryNote(deliveryId: delivery.id, text: text),
),
),
);
}
Future<void> _pickImage(BuildContext context) async {
// Bloc vor dem await greifen — danach kein context-Zugriff über den
// async-Gap.
final tourBloc = context.read<TourBloc>();
final picker = ImagePicker();
// Bild schon on-device runterskalieren + JPEG-komprimieren: Foto-Notizen
// brauchen keine 12-MP-Originale. Spart Upload-/Speicher-/Report-Größe
// (ein 4080×3060-Foto ~2,9 MB → ~200400 KB). 1600 px / Q82 deckt sich mit
// dem Backend-Report-Renderer.
final file = await picker.pickImage(
source: ImageSource.camera,
maxWidth: 1600,
maxHeight: 1600,
imageQuality: 82,
);
if (file == null) return;
final bytes = await file.readAsBytes();
tourBloc.add(
UploadDeliveryNoteImage(
deliveryId: delivery.id,
filename: file.name,
mime: file.mimeType ?? _mimeFromName(file.name),
bytes: bytes,
),
);
}
/// Grober MIME-Fallback, wenn der Picker keinen Typ liefert (Kamera gibt
/// meist JPEG). Reicht für das `Content-Type` des Multipart-Felds.
String _mimeFromName(String name) {
final lower = name.toLowerCase();
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.heic')) return 'image/heic';
if (lower.endsWith('.webp')) return 'image/webp';
return 'image/jpeg';
}
@override
Widget build(BuildContext context) {
final notes = details.notesOf(delivery.id);
// Notizen & Fotos sind nur bei aktiver Lieferung änderbar. Ist die
// Lieferung beendet (abgeschlossen/abgebrochen/pausiert), bleiben sie
// sichtbar, aber read-only: kein FAB, keine Aktions-Menüs, kein Löschen.
final active = delivery.state == DeliveryState.active;
// Sauber in Text-Notizen und Fotos aufteilen — getrennte Sektionen.
final textNotes =
notes.where((n) => n.imageAttachment == null).toList(growable: false);
final photoNotes =
notes.where((n) => n.imageAttachment != null).toList(growable: false);
return Stack(
children: [
ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
children: [
if (!active) ...[
const _ReadOnlyBanner(),
const SizedBox(height: 16),
],
_SectionHeader(text: 'Notizen (${textNotes.length})'),
const SizedBox(height: 8),
if (textNotes.isEmpty)
const _EmptyHint(
icon: Icons.notes,
text: 'Noch keine Notizen erfasst.',
)
else
for (final n in textNotes)
_NoteCard(note: n, deliveryId: delivery.id, active: active),
const SizedBox(height: 24),
_SectionHeader(text: 'Fotos (${photoNotes.length})'),
const SizedBox(height: 8),
if (photoNotes.isEmpty)
const _EmptyHint(
icon: Icons.photo_camera_outlined,
text: 'Noch keine Fotos aufgenommen.',
)
else
for (final n in photoNotes)
_PhotoCard(note: n, deliveryId: delivery.id, active: active),
],
),
// FAB nur bei aktiver Lieferung — sonst ist Hinzufügen gesperrt.
if (active)
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.all(16),
child: _AddMenu(
onAddNote: () => _openAddNoteDialog(context),
onAddImage: () => _pickImage(context),
),
),
),
],
);
}
}
/// Hinweis-Balken oben in der Notiz-Sektion, wenn die Lieferung nicht mehr
/// aktiv ist — Notizen/Fotos sind dann reine Anzeige.
class _ReadOnlyBanner extends StatelessWidget {
const _ReadOnlyBanner();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.lock_outline,
size: 18, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
'Lieferung beendet — Notizen & Fotos können nicht mehr '
'hinzugefügt, geändert oder gelöscht werden.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
);
}
}
class _EmptyHint extends StatelessWidget {
const _EmptyHint({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 12),
Text(
text,
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
),
),
);
}
}
enum _NoteAction { edit, delete }
/// Geteiltes Zeitformat für Notiz- und Foto-Karten.
String _formatNoteTime(DateTime t) =>
'${t.day.toString().padLeft(2, "0")}.${t.month.toString().padLeft(2, "0")}.${t.year} '
'${t.hour.toString().padLeft(2, "0")}:${t.minute.toString().padLeft(2, "0")}';
/// Geteilter Lösch-Bestätigungsdialog. Wording variiert je nachdem, ob eine
/// Text-Notiz oder ein Foto entfernt wird; gefeuert wird in beiden Fällen
/// dasselbe `DeleteDeliveryNote`-Event (Foto ist intern auch eine Notiz).
Future<void> _confirmDeleteNote(
BuildContext context, {
required String deliveryId,
required String noteId,
required bool isPhoto,
}) async {
final tourBloc = context.read<TourBloc>();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(isPhoto ? 'Foto löschen?' : 'Notiz löschen?'),
content: Text(
isPhoto
? 'Das Foto wird dauerhaft entfernt.'
: 'Die Notiz wird dauerhaft entfernt.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Abbrechen'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
foregroundColor: Theme.of(ctx).colorScheme.onError,
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Löschen'),
),
],
),
);
if (confirmed != true) return;
tourBloc.add(DeleteDeliveryNote(deliveryId: deliveryId, noteId: noteId));
}
/// Karte einer **Text-Notiz**. Normale Notizen sind bearbeitbar und löschbar.
/// **System-verwaltete** Grund-Notizen (Mengen-Gutschrift via
/// `creditDeliveryItemId` oder Betrags-Gutschrift via `isAmountCreditNote`)
/// dürfen vom Fahrer nicht manuell geändert/gelöscht werden — sie werden
/// automatisch mit der jeweiligen Gutschrift angelegt und wieder entfernt.
class _NoteCard extends StatelessWidget {
const _NoteCard({
required this.note,
required this.deliveryId,
required this.active,
});
final DeliveryNote note;
final String deliveryId;
/// Nur bei aktiver Lieferung darf bearbeitet/gelöscht werden.
final bool active;
void _openEditDialog(BuildContext context) {
final tourBloc = context.read<TourBloc>();
showDialog<void>(
context: context,
builder: (_) => _NoteEditorDialog(
title: 'Notiz bearbeiten',
initialText: note.text,
onSubmit: (text) => tourBloc.add(
UpdateDeliveryNote(
deliveryId: deliveryId,
noteId: note.id,
text: text,
),
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// System-verwaltete Grund-Notiz: kein manuelles Bearbeiten/Löschen.
final isSystemManaged =
note.creditDeliveryItemId != null || note.isAmountCreditNote;
// Aktions-Menü nur bei aktiver Lieferung UND nicht-System-Notiz.
final canEdit = active && !isSystemManaged;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
note.text ?? '',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 6),
Text(
'Personalnr. ${note.authorPersonalnummer} '
'· ${_formatNoteTime(note.createdAt)}',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
if (!canEdit)
Padding(
padding: const EdgeInsets.only(right: 8, top: 6),
child: Tooltip(
message: isSystemManaged
? 'Automatisch verwaltet wird mit der Gutschrift '
'angelegt und beim Zurücknehmen wieder entfernt.'
: 'Lieferung beendet Notiz nicht mehr änderbar.',
child: Icon(
Icons.lock_outline,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else
PopupMenuButton<_NoteAction>(
icon: const Icon(Icons.more_vert),
tooltip: 'Notiz-Aktionen',
onSelected: (action) {
switch (action) {
case _NoteAction.edit:
_openEditDialog(context);
case _NoteAction.delete:
_confirmDeleteNote(
context,
deliveryId: deliveryId,
noteId: note.id,
isPhoto: false,
);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: _NoteAction.edit,
child: Row(
children: [
Icon(Icons.edit_outlined),
SizedBox(width: 12),
Text('Bearbeiten'),
],
),
),
PopupMenuItem(
value: _NoteAction.delete,
child: Row(
children: [
Icon(Icons.delete_outline,
color: theme.colorScheme.error),
const SizedBox(width: 12),
Text(
'Löschen',
style: TextStyle(color: theme.colorScheme.error),
),
],
),
),
],
),
],
),
),
);
}
}
/// Karte eines **Fotos** — Thumbnail (Tap → formatfüllend) plus Metazeile
/// mit Lösch-Button. Kein Inline-Edit (ein Foto bearbeitet man nicht).
class _PhotoCard extends StatelessWidget {
const _PhotoCard({
required this.note,
required this.deliveryId,
required this.active,
});
final DeliveryNote note;
final String deliveryId;
/// Nur bei aktiver Lieferung darf das Foto gelöscht werden.
final bool active;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.only(bottom: 8),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_NoteImageThumb(
attachmentId: note.imageAttachment!,
deleted: note.imageAttachmentDeleted,
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 8),
child: Row(
children: [
Icon(
Icons.photo_camera_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'Personalnr. ${note.authorPersonalnummer} '
'· ${_formatNoteTime(note.createdAt)}',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (active)
IconButton(
icon: Icon(
Icons.delete_outline,
color: theme.colorScheme.error,
),
tooltip: 'Foto löschen',
onPressed: () => _confirmDeleteNote(
context,
deliveryId: deliveryId,
noteId: note.id,
isPhoto: true,
),
)
else
Padding(
padding: const EdgeInsets.only(right: 8),
child: Tooltip(
message: 'Lieferung beendet Foto nicht mehr löschbar.',
child: Icon(
Icons.lock_outline,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
),
);
}
}
/// Thumbnail einer Bild-Notiz; Tap öffnet das Bild formatfüllend mit
/// Zoom/Pan.
class _NoteImageThumb extends StatelessWidget {
const _NoteImageThumb({required this.attachmentId, this.deleted = false});
final String attachmentId;
/// Lokale Bilddatei nach Report-Upload gelöscht → Hinweis statt Vorschau.
final bool deleted;
void _openFull(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
),
body: Center(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 5,
child: AttachmentImage(
attachmentId: attachmentId,
width: 2048,
height: 2048,
quality: 90,
fit: BoxFit.contain,
deleted: deleted,
),
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
// Kein eigenes Clipping — die umgebende `_PhotoCard` clippt bereits
// (Clip.antiAlias), sonst gäbe es doppelt gerundete Ecken.
return GestureDetector(
// Gelöschtes Bild → kein Vollbild öffnen (es gibt nichts zu laden).
onTap: deleted ? null : () => _openFull(context),
child: SizedBox(
height: 160,
width: double.infinity,
child: AttachmentImage(
attachmentId: attachmentId,
width: 600,
height: 600,
deleted: deleted,
),
),
);
}
}
// ─── Add-Menu (FAB) ─────────────────────────────────────────────────────
enum _AddAction { note, image }
class _AddMenu extends StatelessWidget {
const _AddMenu({required this.onAddNote, required this.onAddImage});
final VoidCallback onAddNote;
final VoidCallback onAddImage;
@override
Widget build(BuildContext context) {
return PopupMenuButton<_AddAction>(
tooltip: 'Hinzufügen',
onSelected: (action) {
switch (action) {
case _AddAction.note:
onAddNote();
case _AddAction.image:
onAddImage();
}
},
itemBuilder: (context) => const [
PopupMenuItem(
value: _AddAction.note,
child: Row(
children: [
Icon(Icons.edit_note),
SizedBox(width: 12),
Text('Notiz schreiben'),
],
),
),
PopupMenuItem(
value: _AddAction.image,
child: Row(
children: [
Icon(Icons.camera_alt_outlined),
SizedBox(width: 12),
Text('Foto aufnehmen'),
],
),
),
],
child: FloatingActionButton.extended(
onPressed: null, // PopupMenuButton fängt den Tap
icon: const Icon(Icons.add),
label: const Text('Hinzufügen'),
),
);
}
}
// ─── Notiz-Dialog (Text) ────────────────────────────────────────────────
/// Editor-Dialog für Text-Notizen — geteilt zwischen „Hinzufügen" und
/// „Bearbeiten". Liefert den getrimmten Text per [onSubmit]; der Aufrufer
/// entscheidet, ob daraus ein `AddDeliveryNote` oder `UpdateDeliveryNote`
/// wird.
class _NoteEditorDialog extends StatefulWidget {
const _NoteEditorDialog({
required this.title,
required this.onSubmit,
this.initialText,
});
final String title;
final void Function(String text) onSubmit;
final String? initialText;
@override
State<_NoteEditorDialog> createState() => _NoteEditorDialogState();
}
class _NoteEditorDialogState extends State<_NoteEditorDialog> {
late final TextEditingController _controller =
TextEditingController(text: widget.initialText ?? '');
bool _empty = true;
@override
void initState() {
super.initState();
_empty = _controller.text.trim().isEmpty;
_controller.addListener(() {
setState(() => _empty = _controller.text.trim().isEmpty);
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _save() {
final text = _controller.text.trim();
if (text.isEmpty) return;
widget.onSubmit(text);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.55,
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
widget.title,
style: Theme.of(context).textTheme.titleLarge,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 12),
// TODO(B6): Templates-Dropdown ergänzen, sobald Backend
// Notiz-Templates als Stammdaten anbietet.
Expanded(
child: TextField(
controller: _controller,
autofocus: true,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
decoration: const InputDecoration(
labelText: 'Notiz',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Abbrechen'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _empty ? null : _save,
icon: const Icon(Icons.save),
label: const Text('Speichern'),
),
],
),
],
),
),
),
);
}
}

View File

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

View File

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

View File

@ -0,0 +1,355 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/feature_flags.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';
/// Gutschriften-Editor: ±10 €, max 150 €, Begründung Pflicht.
///
/// Backend-gestützt: „Speichern" feuert `SetDeliveryCredit`, „Entfernen"
/// `RemoveDeliveryCredit` am `TourBloc` → `POST /deliveries/{id}/credit`
/// (append-only, idempotent). Der aktuelle Stand kommt aus dem Tour-Aggregat
/// (`TourDetails.creditOf`).
class DiscountEditor extends StatefulWidget {
const DiscountEditor({
super.key,
required this.deliveryId,
required this.active,
});
final String deliveryId;
/// Nur bei aktiver Lieferung darf die Gutschrift geändert werden. Bei
/// abgeschlossener/abgebrochener/pausierter Lieferung bleibt der Editor
/// sichtbar, aber gesperrt (reine Anzeige des gespeicherten Stands).
final bool active;
@override
State<DiscountEditor> createState() => _DiscountEditorState();
}
class _DiscountEditorState extends State<DiscountEditor> {
static const int step = 10; // €-Schrittweite (nur die Stepper-Variante)
static const int max = 150; // € Obergrenze
static const int maxCents = max * 100;
/// Betrag in Cent — erlaubt Dezimalbeträge (z. B. 19,99 € = 1999).
int _amountCents = 0;
late final TextEditingController _reasonController;
late final TextEditingController _amountController;
@override
void initState() {
super.initState();
_reasonController = TextEditingController();
// Einmalige Übernahme des aktuellen Server-Stands aus dem Bloc — VOR dem
// Anhängen des Listeners, damit das Setzen des Textes kein `setState`
// (über den Listener) während des ersten Builds auslöst.
final state = context.read<TourBloc>().state;
if (state is TourLoaded) {
final current = state.details.creditOf(widget.deliveryId);
if (current != null) {
_amountCents = current.amountCents;
_reasonController.text = current.reason;
}
}
// Freitext-Betragsfeld (Dezimal, € mit Cent): vorbelegt; Listener parst die
// Eingabe in `_amountCents`. Erst NACH dem Vorbelegen anhängen.
_amountController = TextEditingController(text: _formatCents(_amountCents));
_amountController.addListener(() {
setState(() => _amountCents = _parseCents(_amountController.text) ?? 0);
});
_reasonController.addListener(() => setState(() {}));
}
@override
void dispose() {
_reasonController.dispose();
_amountController.dispose();
super.dispose();
}
/// "19,99" / "19.99" / "20" → Cent. `null` bei leer/ungültig.
static int? _parseCents(String raw) {
final t = raw.trim().replaceAll(',', '.');
if (t.isEmpty) return null;
final euros = double.tryParse(t);
if (euros == null) return null;
return (euros * 100).round();
}
/// Cent → Anzeige-String in € (mit Komma). 0 → leer.
static String _formatCents(int cents) {
if (cents <= 0) return '';
if (cents % 100 == 0) return '${cents ~/ 100}';
return (cents / 100).toStringAsFixed(2).replaceAll('.', ',');
}
bool get _canDecrement => widget.active && _amountCents > 0;
bool get _canIncrement =>
widget.active && _amountCents + step * 100 <= maxCents;
bool get _isReasonValid => _reasonController.text.trim().isNotEmpty;
/// Backend-Regel: >0, ≤150 €. (Beliebige Beträge inkl. Cent.)
bool get _amountValid => _amountCents > 0 && _amountCents <= maxCents;
bool get _canSave => widget.active && _amountValid && _isReasonValid;
// Stepper-Variante (Feature-Flag): bewegt sich in 10-€-Schritten. Das
// Textfeld ist dann nicht sichtbar, daher kein Controller-Sync nötig.
void _decrement() {
if (!_canDecrement) return;
setState(() => _amountCents = (_amountCents - step * 100).clamp(0, maxCents));
}
void _increment() {
if (!_canIncrement) return;
setState(() => _amountCents = (_amountCents + step * 100).clamp(0, maxCents));
}
String _actorCarId(BuildContext context) {
final state = context.read<CarSelectBloc>().state;
if (state is CarSelectComplete) return state.selectedCar.id;
return '00000000-0000-0000-0000-000000000000';
}
void _save() {
context.read<TourBloc>().add(SetDeliveryCredit(
deliveryId: widget.deliveryId,
amountCents: _amountCents,
reason: _reasonController.text.trim(),
actorCarId: _actorCarId(context),
));
}
void _remove() {
context.read<TourBloc>().add(RemoveDeliveryCredit(
deliveryId: widget.deliveryId,
actorCarId: _actorCarId(context),
));
setState(() {
_amountCents = 0;
_reasonController.clear();
_amountController.clear();
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocBuilder<TourBloc, TourState>(
buildWhen: (a, b) {
if (a is! TourLoaded || b is! TourLoaded) return true;
return a.details.creditOf(widget.deliveryId) !=
b.details.creditOf(widget.deliveryId);
},
builder: (context, state) {
final current = state is TourLoaded
? state.details.creditOf(widget.deliveryId)
: null;
final isSaved = current != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Betrag',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
// Default: freies Betrags-Textfeld. Hinter dem Feature-Flag
// `discountAmountStepper` liegt die ursprüngliche +/-Variante.
if (FeatureFlags.discountAmountStepper)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton.filled(
onPressed: _canDecrement ? _decrement : null,
icon: const Icon(Icons.remove),
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
_canDecrement ? Colors.red.shade400 : Colors.grey,
),
),
),
const SizedBox(width: 16),
Column(
children: [
Text(
'${_amountCents ~/ 100}',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
Text(
'max. $max € · Schritt $step',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(width: 16),
IconButton.filled(
onPressed: _canIncrement ? _increment : null,
icon: const Icon(Icons.add),
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
_canIncrement ? Colors.green.shade600 : Colors.grey,
),
),
),
],
)
else
TextField(
controller: _amountController,
enabled: widget.active,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
border: const OutlineInputBorder(),
isDense: true,
prefixText: '',
hintText: '0,00',
helperText: 'max. $max € · Cent erlaubt (z. B. 19,99)',
// Fehlertext nur bei nicht-leerer, ungültiger Eingabe.
errorText: (widget.active &&
_amountController.text.trim().isNotEmpty &&
!_amountValid)
? 'Betrag muss > 0 und ≤ $max € sein'
: null,
),
),
const SizedBox(height: 16),
Text(
'Begründung (Pflicht)',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
TextField(
controller: _reasonController,
enabled: widget.active,
minLines: 2,
maxLines: 4,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'z. B. Transportschaden, Verzögerung …',
),
),
const SizedBox(height: 12),
if (!widget.active) ...[
_LockedHint(
text: isSaved
? 'Lieferung abgeschlossen — Gutschrift nicht mehr änderbar.'
: 'Gutschrift nur bei aktiver Lieferung änderbar.',
),
const SizedBox(height: 12),
],
if (isSaved && widget.active) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle_outline,
size: 14,
color: Colors.green.shade700,
),
const SizedBox(width: 4),
Text(
'Gespeichert',
style: TextStyle(
fontSize: 12,
color: Colors.green.shade800,
),
),
],
),
),
const SizedBox(height: 8),
],
// Buttons in einem Wrap: brechen auf schmalen Cards um, statt
// (wie zuvor in einer Row mit Spacer) rechts überzulaufen. Volle
// Breite, damit WrapAlignment.end rechtsbündig wirkt.
SizedBox(
width: double.infinity,
child: Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
if (isSaved)
TextButton.icon(
onPressed: widget.active ? _remove : null,
icon: const Icon(Icons.delete_outline),
label: const Text('Entfernen'),
),
FilledButton.icon(
onPressed: _canSave ? _save : null,
icon: const Icon(Icons.save),
label: Text(isSaved ? 'Aktualisieren' : 'Speichern'),
),
],
),
),
],
);
},
);
}
}
/// Kleiner Hinweis-Balken, wenn eine Aktion gesperrt ist (Lieferung nicht
/// aktiv). Bewusst dezent — der Editor bleibt als Anzeige sichtbar.
class _LockedHint extends StatelessWidget {
const _LockedHint({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.lock_outline,
size: 16, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
}

View File

@ -1,57 +0,0 @@
import 'dart:typed_data';
import 'package:hl_lieferservice/dto/discount_add_response.dart';
import 'package:hl_lieferservice/dto/discount_remove_response.dart';
import 'package:hl_lieferservice/dto/discount_update_response.dart';
import 'package:hl_lieferservice/feature/delivery/detail/repository/note_repository.dart';
import 'package:hl_lieferservice/feature/delivery/detail/service/notes_service.dart';
import 'package:hl_lieferservice/feature/delivery/service/tour_service.dart';
import 'package:hl_lieferservice/model/delivery.dart';
class DeliveryRepository {
DeliveryRepository({required this.service});
TourService service;
Future<String?> unscan(String articleId, int newAmount, String reason) async {
return await service.unscanArticle(articleId, newAmount, reason);
}
Future<void> resetScan(String articleId) async {
return await service.resetScannedArticleAmount(articleId);
}
Future<void> uploadDriverSignature(String deliveryId, Uint8List signature) async {
NoteRepository noteRepository = NoteRepository(service: NoteService());
await noteRepository.addNamedImage(deliveryId, signature, "delivery_${deliveryId}_signature_driver.jpg");
}
Future<void> uploadCustomerSignature(String deliveryId, Uint8List signature) async {
NoteRepository noteRepository = NoteRepository(service: NoteService());
await noteRepository.addNamedImage(deliveryId, signature, "delivery_${deliveryId}_signature_customer.jpg");
}
Future<DiscountAddResponseDTO> addDiscount(
String deliveryId,
String reason,
int value,
) {
return service.addDiscount(deliveryId, value, reason);
}
Future<DiscountRemoveResponseDTO> removeDiscount(String deliveryId) {
return service.removeDiscount(deliveryId);
}
Future<DiscountUpdateResponseDTO> updateDiscount(
String deliveryId,
String? reason,
int? value,
) {
return service.updateDiscount(deliveryId, reason, value);
}
Future<void> updateDelivery(Delivery delivery) {
return service.updateDelivery(delivery);
}
}

View File

@ -1,96 +0,0 @@
import 'dart:typed_data';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/feature/delivery/detail/service/notes_service.dart';
import 'package:rxdart/rxdart.dart';
class NoteRepository {
final NoteService service;
final _notesStream = BehaviorSubject<List<Note>?>.seeded(null);
final _imageNoteStream = BehaviorSubject<List<ImageNote>?>.seeded(null);
final _noteTemplateStream = BehaviorSubject<List<NoteTemplate>?>.seeded(null);
Stream<List<Note>?> get notes => _notesStream.stream;
Stream<List<ImageNote>?> get images => _imageNoteStream.stream;
Stream<List<NoteTemplate>?> get templates => _noteTemplateStream.stream;
List<Note> get _currentNotes => _notesStream.value ?? [];
List<ImageNote> get _currentImages => _imageNoteStream.value ?? [];
NoteRepository({required this.service});
Future<void> addNote(String deliveryId, String content) async {
final note = await service.addNote(content, int.parse(deliveryId));
_currentNotes.add(note);
_notesStream.add(_currentNotes);
}
Future<void> editNote(String noteId, String content) async {
final newNote = Note(content: content, id: int.parse(noteId));
await service.editNote(newNote);
final currentNotes = _notesStream.value;
final index = _currentNotes.indexWhere((note) => note.id == int.parse(noteId));
if (index != -1) {
_currentNotes[index] = newNote;
_notesStream.add(currentNotes);
}
}
Future<void> deleteNote(String noteId) async {
await service.deleteNote(int.parse(noteId));
final currentNotes = _notesStream.value;
final index = _currentNotes.indexWhere((note) => note.id == int.parse(noteId));
_currentNotes.removeAt(index);
_notesStream.add(currentNotes);
}
Future<void> loadNotes(String deliveryId) async {
var (notes, images) = await service.getNotes(deliveryId);
_notesStream.add(notes);
_imageNoteStream.add(images);
}
Future<void> loadTemplates() async {
_noteTemplateStream.add(await service.getNoteTemplates());
}
Future<void> addImage(String deliveryId, Uint8List bytes) async {
final fileName =
"delivery_note_${deliveryId}_${DateTime.timestamp().microsecondsSinceEpoch}.jpg";
String objectId = await service.uploadImage(
deliveryId,
fileName,
bytes,
"image/png",
);
_currentImages.add(ImageNote.make(objectId, fileName, bytes));
_imageNoteStream.add(_currentImages);
}
Future<void> addNamedImage(String deliveryId, Uint8List bytes, String filename) async {
String objectId = await service.uploadImage(
deliveryId,
filename,
bytes,
"image/png",
);
_currentImages.add(ImageNote.make(objectId, filename, bytes));
_imageNoteStream.add(_currentImages);
}
Future<void> deleteImage(String deliveryId, String objectId) async {
await service.removeImage(objectId);
final index = _currentImages.indexWhere((imageNote) => imageNote.objectId == objectId);
_currentImages.removeAt(index);
_imageNoteStream.add(_currentImages);
}
}

View File

@ -1,327 +0,0 @@
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:hl_lieferservice/dto/note_get_response.dart';
import 'package:hl_lieferservice/feature/delivery/detail/exceptions.dart';
import 'package:hl_lieferservice/services/erpframe.dart';
import 'package:flutter/cupertino.dart';
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import '../../../../dto/basic_response.dart';
import '../../../../dto/note_add_response.dart';
import '../../../../dto/note_template_response.dart';
import '../../../../model/delivery.dart';
import '../../../../util.dart';
import '../../../authentication/exceptions.dart';
class NoteService {
Future<void> deleteNote(int noteId) async {
try {
var response = await http.post(
urlBuilder("_web_deleteNote"),
headers: getSessionOrThrow(),
body: {"id": noteId.toString()},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
debugPrint("NOTE DELETE: ${response.body}");
BasicResponseDTO responseDto = BasicResponseDTO.fromJson(responseJson);
if (responseDto.succeeded == true) {
return;
} else {
throw responseDto.message;
}
} catch (e, st) {
debugPrint("ERROR WHILE DELETING NOTE $noteId");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
Future<void> editNote(Note newNote) async {
try {
var response = await http.post(
urlBuilder("_web_editNote"),
headers: getSessionOrThrow(),
body: {"id": newNote.id.toString(), "note": newNote.content},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
BasicResponseDTO responseDto = BasicResponseDTO.fromJson(responseJson);
if (responseDto.succeeded == true) {
return;
} else {
throw responseDto.message;
}
} catch (e, st) {
debugPrint("ERROR WHILE EDITING NOTE ${newNote.id}");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
Future<List<NoteTemplate>> getNoteTemplates() async {
try {
var response = await http.post(
urlBuilder("_web_getNoteTemplates"),
headers: getSessionOrThrow(),
body: {},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
NoteTemplateResponseDTO responseDto = NoteTemplateResponseDTO.fromJson(
responseJson,
);
if (responseDto.succeeded == true) {
return responseDto.notes.map(NoteTemplate.fromDTO).toList();
} else {
throw responseDto.message;
}
} catch (e, st) {
debugPrint("ERROR WHILE GETTING NOTE TEMPLATES");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
Future<(List<Note>, List<ImageNote>)> getNotes(String deliveryId) async {
try {
var response = await http.post(
urlBuilder("_web_getNotes"),
headers: getSessionOrThrow(),
body: {"delivery_id": deliveryId},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
NoteGetResponseDTO responseDto = NoteGetResponseDTO.fromJson(
responseJson,
);
if (responseDto.succeeded == true) {
List<ImageNote> imageNotes =
responseDto.images
.map((imageNoteDto) => ImageNote.fromDTO(imageNoteDto))
.toList();
final images = await downloadImages(imageNotes.map((note) => note.url).toList());
for (var (index, note) in imageNotes.indexed) {
note.data = await images[index];
}
return (
responseDto.notes
.map((noteDto) => Note.fromDto(noteDto))
.toList(),
imageNotes
);
} else {
throw responseDto.message;
}
} catch (e, st) {
debugPrint("ERROR WHILE GETTING NOTES");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
Future<Note> addNote(String note, int deliveryId) async {
try {
var response = await http.post(
urlBuilder("_web_addNote"),
headers: getSessionOrThrow(),
body: {"receipt_id": deliveryId.toString(), "note": note},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
debugPrint(responseJson.toString());
NoteAddResponseDTO responseDto = NoteAddResponseDTO.fromJson(
responseJson,
);
if (responseDto.succeeded == true) {
return Note.fromDto(responseDto.note!);
} else {
debugPrint("ERROR: ${responseDto.message}");
throw responseDto.message;
}
} catch (e) {
rethrow;
}
}
Future<String> uploadImage(
String deliveryId,
String filename,
Uint8List bytes,
String? mimeType,
) async {
try {
var config = getConfig();
var basePath = "${config.backendUrl}/v1/uploadFile";
var response = await http.get(
Uri.parse(basePath),
headers: getSessionOrThrow(),
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> jsonResponse = jsonDecode(response.body);
debugPrint("GET UPLOADID : ${response.body}");
if (!jsonResponse.containsKey("data")) {
debugPrint("No data structure in uploadFile request");
debugPrint("RAW RESPONSE: ${response.body}");
throw NoteImageAddException();
}
Map<String, dynamic> data = jsonResponse["data"];
if (!data.containsKey("uploadId")) {
debugPrint("No data.uploadId structure in uploadFile request");
debugPrint("RAW RESPONSE: ${response.body}");
throw NoteImageAddException();
}
String uploadId = data["uploadId"];
http.MultipartRequest request = http.MultipartRequest(
"POST",
Uri.parse("$basePath/$uploadId"),
);
HashMap<String, String> header = HashMap();
header["Content-Type"] = "multipart/form-data";
header.addAll(getSessionOrThrow());
request.headers.addAll(header);
request.files.add(
http.MultipartFile.fromBytes(
"file",
bytes,
filename: filename,
contentType: MediaType.parse(mimeType ?? "application/octet-stream"),
),
);
http.Response fileUploadResponse = await http.Response.fromStream(
await request.send(),
);
Map<String, dynamic> fileUploadResponseJson = jsonDecode(
fileUploadResponse.body,
);
debugPrint("UPLOAD IMAGE RESPONSE: ${fileUploadResponse.body}");
if (fileUploadResponseJson["status"]["internalStatus"] != "0") {
debugPrint("Failed to upload image");
debugPrint("RAW: ${fileUploadResponseJson.toString()}");
throw NoteImageAddException();
}
var fileCommitResponse = await http.patch(
Uri.parse("$basePath/$uploadId"),
headers: getSessionOrThrow(),
);
debugPrint("FILE COMMIT BODY: ${fileCommitResponse.body}");
var fileCommitResponseJson = jsonDecode(fileCommitResponse.body);
return fileCommitResponseJson["data"]["~ObjectID"];
} catch (e, st) {
debugPrint("An error occured:");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
Future<List<Future<Uint8List>>> downloadImages(List<String> urls) async {
try {
LocalDocuFrameConfiguration config = getConfig();
return urls.map((url) async {
final response = await http.get(
Uri.parse("${config.backendUrl}$url"),
headers: getSessionOrThrow(),
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
return response.bodyBytes;
}).toList();
} catch (e, st) {
debugPrint("An error occured:");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
Future<void> removeImage(String oid) async {
try {
var response = await http.post(
urlBuilder("_web_removeImage"),
headers: getSessionOrThrow(),
body: {"oid": oid},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
debugPrint(oid);
debugPrint(responseJson.toString());
BasicResponseDTO responseDto = BasicResponseDTO.fromJson(responseJson);
if (responseDto.succeeded == true) {
return;
} else {
debugPrint("ERROR: ${responseDto.message}");
throw responseDto.message;
}
} catch (e, st) {
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
}

View File

@ -1,56 +0,0 @@
import 'package:flutter/cupertino.dart';
class SortingInformation {
String deliveryId;
int position;
SortingInformation({required this.deliveryId, required this.position});
static Map<String, dynamic> toJson(SortingInformation info) {
return {"delivery_id": info.deliveryId, "position": info.position};
}
static SortingInformation fromJson(Map<String, dynamic> json) {
return SortingInformation(
deliveryId: json["delivery_id"].toString(),
position: json["position"],
);
}
}
class SortingInformationContainer {
Map<String, List<String>> cars;
SortingInformationContainer({required this.cars});
static SortingInformationContainer fromJson(Map<String, dynamic> json) {
SortingInformationContainer container = SortingInformationContainer(
cars: {},
);
for (final car in json["cars"].entries) {
List<String> values = [];
for (String value in car.value) {
values.add(value);
}
container.cars[car.key] = values;
}
return container;
}
Map<String, dynamic> toJson() {
Map<String, dynamic> cars = {};
for (final car in this.cars.entries) {
cars[car.key] = car.value;
}
return {"cars": cars};
}
SortingInformationContainer copyWith({Map<String, List<String>>? sorting}) {
return SortingInformationContainer(cars: sorting ?? cars);
}
}

View File

@ -1,16 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
/// Fallback-Page, die der Übersichts- und Beladen-Pfad anzeigt, wenn der
/// Initial-Tour-Load gescheitert ist. Tap auf "Erneut versuchen" feuert
/// `LoadTour` erneut — Account-Filter sitzt jetzt im JWT, daher keine
/// Personalnummer mehr nötig.
class DeliveryLoadingFailedPage extends StatelessWidget {
const DeliveryLoadingFailedPage({super.key});
void _onRetry(BuildContext context) {
Authenticated state = context.read<AuthBloc>().state as Authenticated;
context.read<TourBloc>().add(LoadTour(teamId: state.user.number));
context.read<TourBloc>().add(const LoadTour());
}
@override
@ -21,18 +22,22 @@ class DeliveryLoadingFailedPage extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 72, color: Theme.of(context).colorScheme.error,),
Padding(
padding: const EdgeInsets.only(top: 30),
Icon(
Icons.error_outline,
size: 72,
color: Theme.of(context).colorScheme.error,
),
const Padding(
padding: EdgeInsets.only(top: 30),
child: Text(
"Leider ist es beim Laden der Fahrten zu einem Fehler gekommen.",
'Leider ist es beim Laden der Fahrten zu einem Fehler gekommen.',
),
),
Padding(
padding: const EdgeInsets.only(top: 30),
child: FilledButton(
onPressed: () => _onRetry(context),
child: Text("Erneut versuchen"),
child: const Text('Erneut versuchen'),
),
),
],

View File

@ -1,23 +1,28 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/model/delivery.dart' show DeliveryState;
import 'package:hl_lieferservice/model/tour.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:intl/intl.dart';
/// Kopf-Karte der Auslieferungs-Übersicht. Zeigt Datum, Anzahl Lieferungen
/// und Fortschrittsbalken — gefiltert auf das aktuell gewählte Fahrzeug,
/// damit der Fahrer seine eigene Tagesleistung sieht.
class DeliveryInfo extends StatelessWidget {
final Tour tour;
final TourDetails details;
final String? selectedCarId;
const DeliveryInfo({super.key, required this.tour, this.selectedCarId});
const DeliveryInfo({super.key, required this.details, this.selectedCarId});
@override
Widget build(BuildContext context) {
final String date = DateFormat("dd.MM.yyyy").format(tour.date);
final date = DateFormat('dd.MM.yyyy').format(details.tour.date);
final relevantDeliveries = selectedCarId != null
? tour.deliveries.where((d) => d.carId == selectedCarId).toList()
: tour.deliveries;
? details.deliveries
.where((d) => d.assignedCarId == selectedCarId)
.toList()
: details.deliveries;
final total = relevantDeliveries.length;
final done = relevantDeliveries
.where((d) => d.state == DeliveryState.finished)
.where((d) => d.state == DeliveryState.completed)
.length;
final progress = total > 0 ? done / total : 0.0;
final allDone = total > 0 && done == total;
@ -37,11 +42,11 @@ class DeliveryInfo extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.calendar_month),
const Padding(
children: const [
Icon(Icons.calendar_month),
Padding(
padding: EdgeInsets.only(left: 5),
child: Text("Datum"),
child: Text('Datum'),
),
],
),
@ -53,15 +58,15 @@ class DeliveryInfo extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.local_shipping_outlined),
const Padding(
children: const [
Icon(Icons.local_shipping_outlined),
Padding(
padding: EdgeInsets.only(left: 5),
child: Text("Lieferungen"),
child: Text('Lieferungen'),
),
],
),
Text("$done / $total"),
Text('$done / $total'),
],
),
const SizedBox(height: 10),

View File

@ -1,153 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart';
import '../../../../widget/operations/bloc/operation_bloc.dart';
import '../../detail/bloc/note_bloc.dart';
import '../../detail/repository/note_repository.dart';
import '../../detail/service/notes_service.dart';
class DeliveryListItem extends StatelessWidget {
final Delivery delivery;
final double? distance;
const DeliveryListItem({
super.key,
required this.delivery,
this.distance,
});
void _goToDelivery(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => NoteBloc(
deliveryId: delivery.id,
opBloc: context.read<OperationBloc>(),
authBloc: context.read<AuthBloc>(),
repository: NoteRepository(service: NoteService()),
),
child: DeliveryDetail(deliveryId: delivery.id),
),
),
);
}
(Color, Color, IconData, String) _stateStyle(BuildContext context) {
switch (delivery.state) {
case DeliveryState.finished:
return (
Colors.green.withValues(alpha: 0.07),
Colors.green.withValues(alpha: 0.35),
Icons.check_circle_rounded,
"Abgeschlossen",
);
case DeliveryState.canceled:
return (
Colors.red.withValues(alpha: 0.07),
Colors.red.withValues(alpha: 0.35),
Icons.cancel_rounded,
"Storniert",
);
case DeliveryState.onhold:
return (
Colors.orange.withValues(alpha: 0.07),
Colors.orange.withValues(alpha: 0.35),
Icons.pause_circle_rounded,
"Pausiert",
);
case DeliveryState.ongoing:
final distanceLabel = distance != null && !distance!.isNaN
? "${distance!.toStringAsFixed(1)} km"
: "";
return (
Theme.of(context).colorScheme.surfaceContainerLow,
Colors.transparent,
Icons.local_shipping_outlined,
distanceLabel,
);
}
}
@override
Widget build(BuildContext context) {
final (cardColor, borderColor, icon, statusLabel) = _stateStyle(context);
final isOngoing = delivery.state == DeliveryState.ongoing;
final iconColor = switch (delivery.state) {
DeliveryState.finished => Colors.green,
DeliveryState.canceled => Colors.red,
DeliveryState.onhold => Colors.orange,
DeliveryState.ongoing => Theme.of(context).primaryColor,
};
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: borderColor),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _goToDelivery(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(icon, color: iconColor, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
delivery.customer.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: isOngoing ? null : iconColor,
),
),
const SizedBox(height: 2),
Text(
delivery.customer.address.toString(),
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
statusLabel,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isOngoing
? Theme.of(context).colorScheme.onSurfaceVariant
: iconColor,
),
),
const SizedBox(height: 4),
Icon(
Icons.arrow_forward_ios,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
],
),
),
),
);
}
}

View File

@ -1,126 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/feature/delivery/overview/presentation/delivery_overview.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'delivery_item.dart';
class DeliveryList extends StatefulWidget {
final String? selectedCarId;
final SortType sortType;
const DeliveryList({super.key, this.selectedCarId, required this.sortType});
@override
State<StatefulWidget> createState() => _DeliveryListState();
}
class _DeliveryListState extends State<DeliveryList> {
@override
void initState() {
super.initState();
}
Widget _showCustomSortedList(
List<Delivery> deliveries,
List<String> sortingInformation,
Map<String, double> distances,
) {
return ListView.separated(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
separatorBuilder: (context, index) => const Divider(height: 0),
itemBuilder: (context, index) {
String id = sortingInformation[index];
Delivery delivery = deliveries.firstWhere(
(delivery) =>
id == delivery.id &&
delivery.carId == widget.selectedCarId,
);
return DeliveryListItem(
delivery: delivery,
distance: distances[delivery.id],
);
},
itemCount: sortingInformation.length,
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
final currentState = state;
if (currentState is TourLoaded) {
if (widget.sortType == SortType.custom) {
return _showCustomSortedList(
currentState.tour.deliveries,
currentState.sortingInformation[widget.selectedCarId.toString()] ?? [],
currentState.distances ?? {},
);
}
final allDeliveries = currentState.tour.deliveries
.where((d) => d.carId == widget.selectedCarId)
.toList();
if (allDeliveries.isEmpty) {
return ListView(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
children: const [
Center(child: Text("Keine Auslieferungen gefunden")),
],
);
}
final ongoing = allDeliveries
.where((d) => d.state == DeliveryState.ongoing)
.toList();
final nonOngoing = allDeliveries
.where((d) => d.state != DeliveryState.ongoing)
.toList();
int Function(Delivery, Delivery) comparator;
switch (widget.sortType) {
case SortType.nameAsc:
comparator = (a, b) => a.customer.name.compareTo(b.customer.name);
break;
case SortType.nameDesc:
comparator = (a, b) => b.customer.name.compareTo(a.customer.name);
break;
case SortType.distance:
comparator = (a, b) =>
(currentState.distances?[a.id] ?? 0.0)
.compareTo(currentState.distances?[b.id] ?? 0.0);
break;
default:
comparator = (a, b) => a.customer.name.compareTo(b.customer.name);
}
ongoing.sort(comparator);
nonOngoing.sort(comparator);
final sorted = [...ongoing, ...nonOngoing];
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 8),
itemCount: sorted.length,
itemBuilder: (context, index) => DeliveryListItem(
delivery: sorted[index],
distance: currentState.distances?[sorted[index].id],
),
);
}
return Center(child: CircularProgressIndicator());
},
);
}
}

View File

@ -1,151 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/util.dart';
import 'package:hl_lieferservice/model/delivery.dart';
class CustomSortDialog extends StatefulWidget {
const CustomSortDialog({super.key, this.selectedCarId});
final String? selectedCarId;
@override
State<StatefulWidget> createState() => _CustomSortDialogState();
}
class _CustomSortDialogState extends State<CustomSortDialog> {
late List<String> _localSortedList;
@override
void initState() {
super.initState();
final state = context.read<TourBloc>().state;
if (state is TourLoaded) {
_localSortedList = [
...state.sortingInformation[widget.selectedCarId.toString()] ?? [],
];
} else {
_localSortedList = [];
}
}
Widget _information() {
return Padding(
padding: EdgeInsets.only(top: 15),
child: Column(
children: [
Padding(
padding: EdgeInsets.all(15),
child: Row(
children: [
Padding(
padding: EdgeInsets.only(right: 15),
child: Icon(Icons.info_outline, color: Colors.blueAccent),
),
Expanded(
child: Text(
"Ziehen Sie die einzelnen Lieferungen mit dem Finger in die gewünschte Position.",
),
),
],
),
),
Divider(),
_sortableList(),
],
),
);
}
Widget _sortableList() {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
final currentState = state;
if (currentState is TourLoaded) {
return Expanded(
child: ReorderableListView(
onReorder: (oldIndex, newIndex) {
setState(() {
_localSortedList = reorderList(
_localSortedList,
oldIndex,
newIndex,
);
});
context.read<TourBloc>().add(
ReorderDeliveryEvent(
newPosition: newIndex,
oldPosition: oldIndex,
carId: widget.selectedCarId.toString(),
),
);
},
children:
_localSortedList
.map((id) {
Delivery delivery = currentState.tour.deliveries
.firstWhere((delivery) => delivery.id == id);
int pos = _localSortedList.indexOf(id) + 1;
return ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
child: Text(
"$pos",
style: TextStyle(
color:
Theme.of(context).colorScheme.onSecondary,
),
),
),
title: Text(delivery.customer.name),
subtitle: Text(
delivery.customer.address.toString(),
style: TextStyle(fontSize: 11),
),
trailing: Icon(Icons.drag_handle),
key: Key("reorder-item-${delivery.id}"),
);
})
.toList(),
),
);
}
return Center(child: CircularProgressIndicator());
},
);
}
@override
Widget build(BuildContext context) {
return Dialog(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 20, right: 10, top: 15),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Fahrten sortieren",
style: Theme.of(context).textTheme.headlineSmall,
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
),
Expanded(child: _information()),
],
),
);
}
}

View File

@ -1,69 +1,32 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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';
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
import '../../bloc/tour_bloc.dart';
import '../../bloc/tour_state.dart';
/// Inhalt der Phase "Ausliefern". Sortieren und Beladen werden über eigene
/// Pages und das Phasen-Routing in `Home` gerendert — diese Page übernimmt
/// nur noch die letzte Phase. Der Phasen-Stepper bleibt sichtbar, damit der
/// Fahrer bei Bedarf zurückspringen kann; das BottomNav der Auslieferung
/// liegt im umgebenden `Home`-Scaffold.
class DeliveryOverviewPage extends StatefulWidget {
class DeliveryOverviewPage extends StatelessWidget {
const DeliveryOverviewPage({super.key});
@override
State<StatefulWidget> createState() => _DeliveryOverviewPageState();
}
class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
Widget _buildOverviewWithBanner({
required Tour tour,
required String bannerText,
}) {
return Column(
children: [
Material(
color: Colors.amber.shade100,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 12),
Expanded(child: Text(bannerText)),
],
),
),
),
Expanded(
child: DeliveryOverview(tour: tour),
),
],
);
}
@override
Widget build(BuildContext context) {
final carState = context.watch<CarSelectBloc>().state;
final carId = carState is CarSelectComplete
? carState.selectedCar.id.toString()
: "";
final carId =
carState is CarSelectComplete ? carState.selectedCar.id : '';
return Scaffold(
// Drawer ist hier ebenfalls aktiv, damit der Menü-Button des Steppers
// konsistent über alle Phasen funktioniert.
drawer: const HomeAppDrawer(),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(140),
@ -72,25 +35,79 @@ class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
carId: carId,
),
),
body: BlocBuilder<TourBloc, TourState>(
body: BlocConsumer<TourBloc, TourState>(
listenWhen: (prev, next) =>
next is TourLoaded && next.refreshError != null,
listener: (context, state) {
if (state is TourLoaded && state.refreshError != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.refreshError!)),
);
}
},
builder: (context, state) {
if (state is TourLoaded) {
if (state.distances == null) {
return _buildOverviewWithBanner(
tour: state.tour,
bannerText: "Berechne Distanzen…",
);
}
return DeliveryOverview(tour: state.tour);
switch (state) {
case TourLoaded(:final details):
return _OverviewBody(details: details);
case TourEmpty():
return const _EmptyTourBody();
case TourLoadFailed():
return const DeliveryLoadingFailedPage();
case TourInitial():
case TourLoading():
return const Center(child: CircularProgressIndicator());
}
if (state is TourLoadingFailed) {
return DeliveryLoadingFailedPage();
}
return const Center(child: CircularProgressIndicator());
},
),
);
}
}
class _OverviewBody extends StatelessWidget {
const _OverviewBody({required this.details});
final TourDetails details;
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () async {
context.read<TourBloc>().add(const RefreshTour());
},
child: DeliveryOverview(details: details),
);
}
}
class _EmptyTourBody extends StatelessWidget {
const _EmptyTourBody();
@override
Widget build(BuildContext context) {
// Wenn der ERP-Sync für heute keine Tour gemeldet hat, ist das ein
// normaler Zustand — kein Fehler. UX-Hinweis und Pull-to-refresh.
return RefreshIndicator(
onRefresh: () async {
context.read<TourBloc>().add(const RefreshTour());
},
child: ListView(
children: const [
SizedBox(height: 120),
Icon(Icons.event_busy, size: 64, color: Colors.grey),
SizedBox(height: 16),
Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: Text(
'Für heute ist keine Tour zugewiesen.\n'
'Zum Aktualisieren nach unten ziehen.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16),
),
),
),
],
),
);
}
}

View File

@ -1,6 +1,9 @@
import 'package:collection/collection.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/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
@ -8,8 +11,6 @@ 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/model/delivery_phase.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
@ -20,18 +21,11 @@ import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
/// * Tab "Verfügbar" — alle Lieferungen, die noch keinem Fahrzeug
/// zugeordnet sind. Multiselect, Bulk-Bestätigung per BottomBar-Button.
/// * Tab "Vergeben" — Lieferungen, die bereits zugeordnet sind. Tap auf
/// eigene Lieferung → Freigabe-Dialog; Tap auf fremde → Umlade-Dialog.
/// * Persistent untere BottomBar: "Weiter zum Sortieren" — wechselt die
/// Phase. Es gibt bewusst keinen Zwang, dass alle Lieferungen verteilt
/// sein müssen; der Fahrer entscheidet wann er weiterzieht.
/// eigene → Freigabe-Dialog; Tap auf fremde → Umlade-Dialog.
/// * Persistente BottomBar: "Weiter zum Sortieren" — wechselt die Phase.
class DeliverySelectionPage extends StatefulWidget {
const DeliverySelectionPage({
super.key,
required this.selectedCarId,
});
const DeliverySelectionPage({super.key, required this.selectedCarId});
/// ID des aktuell gewählten Fahrzeugs (Eigene Lieferungen / Ziel von
/// Übernahmen).
final String selectedCarId;
@override
@ -39,86 +33,63 @@ class DeliverySelectionPage extends StatefulWidget {
}
class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
/// Lokale Multi-Selektion im Tab "Verfügbar". Wird nach erfolgreichem
/// Bulk-Assign geleert.
final Set<String> _selectedIds = <String>{};
/// True während sequentieller Assign-Calls — schaltet den
/// Bestätigungs-Button auf Spinner und disabled.
bool _isAssigning = false;
String get _carIdString => widget.selectedCarId.toString();
/// Sucht das Plate eines Autos in der Tour-Driver-Liste. Liefert "?" als
/// Fallback, falls die Zuordnung nicht (mehr) im Team enthalten ist —
/// z. B. nach Personalwechsel zwischen Tour-Synchronisationen.
String _plateFor(String? carId, Tour tour) {
if (carId == null) return "?";
final car = tour.driver.cars.firstWhereOrNull((c) => c.id == carId);
return car?.plate ?? "?";
/// Sucht das Kennzeichen eines Fahrzeugs in der aktuell geladenen
/// CarsBloc-Liste. Liefert "?" als Fallback, wenn das Auto nicht (mehr)
/// im Account ist — z. B. nach Personalwechsel zwischen Tour-Syncs.
String _plateFor(String? carId) {
if (carId == null) return '?';
final carsState = context.read<CarsBloc>().state;
if (carsState is! CarsLoaded) return '?';
for (final c in carsState.cars) {
if (c.id == carId) return c.plate;
}
return '?';
}
/// Bulk-Assign der aktuell selektierten Lieferungen an das eigene Auto.
/// Sequentiell, damit der lokale Tour-Stream nach jedem Schritt
/// konsistent ist und die Listen-Filter live mitwandern. Bei Fehlern
/// wird eine SnackBar gezeigt und die Selektion bleibt erhalten.
Future<void> _confirmSelection() async {
if (_selectedIds.isEmpty || _isAssigning) return;
setState(() => _isAssigning = true);
final tourBloc = context.read<TourBloc>();
void _confirmSelection() {
if (_selectedIds.isEmpty) return;
final ids = List<String>.from(_selectedIds);
try {
for (final id in ids) {
tourBloc.add(
AssignCarEvent(deliveryId: id, carId: _carIdString),
);
}
// Hinweis: TourBloc verarbeitet die Events asynchron; lokale
// Tour-Updates erfolgen über den Stream. Wir leeren die Selektion
// optimistisch, damit der Fahrer ein klares Feedback bekommt.
if (!mounted) return;
setState(_selectedIds.clear);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Fehler beim Übernehmen: $e")),
);
} finally {
if (mounted) setState(() => _isAssigning = false);
}
}
/// Wechselt in die nächste Phase (Sortieren). Der Fahrer kann jederzeit
/// zurückspringen — die persistierte Phase wird über den Stepper-Tap
/// zurückgesetzt.
void _goToSorting() {
context.read<PhaseBloc>().add(
PhaseSet(
carId: _carIdString,
phase: DeliveryPhase.sortieren,
// EIN Bulk-Event statt N parallel laufender Single-Events: vermeidet
// die Race-Condition, bei der `flutter_bloc`s default-concurrent
// Event-Processing parallele Handler den Initial-State lesen lässt
// und sich am Ende beim `emit` gegenseitig überschreiben.
context.read<TourBloc>().add(
AssignCarToDeliveries(
deliveryIds: ids,
carId: widget.selectedCarId,
),
);
setState(_selectedIds.clear);
}
Future<void> _showReleaseDialog(Delivery delivery) async {
void _goToSorting() {
context.read<PhaseBloc>().add(
PhaseSet(carId: widget.selectedCarId, phase: DeliveryPhase.sortieren),
);
}
Future<void> _showReleaseDialog(Delivery delivery, TourDetails details) async {
final customer = details.customerOf(delivery);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Lieferung freigeben"),
title: const Text('Lieferung freigeben'),
content: Text(
"${delivery.customer.name} wurde Ihrem Fahrzeug zugeordnet. "
"Möchten Sie diese Lieferung wieder freigeben?",
'${customer?.name ?? 'Diese Lieferung'} wurde Ihrem Fahrzeug '
'zugeordnet. Möchten Sie sie wieder freigeben?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text("Abbrechen"),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text("Freigeben"),
child: const Text('Freigeben'),
),
],
),
@ -126,54 +97,57 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
if (result != true || !mounted) return;
context.read<TourBloc>().add(
UnassignDeliveryEvent(deliveryId: delivery.id),
AssignCarToDelivery(deliveryId: delivery.id, carId: null),
);
}
Future<void> _showTakeoverDialog(Delivery delivery, Tour tour) async {
final foreignPlate = _plateFor(delivery.carId, tour);
final ownPlate = _plateFor(widget.selectedCarId, tour);
Future<void> _showTakeoverDialog(
Delivery delivery,
TourDetails details,
) async {
final customer = details.customerOf(delivery);
final foreignPlate = _plateFor(delivery.assignedCarId);
final ownPlate = _plateFor(widget.selectedCarId);
final theme = Theme.of(context);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Lieferung umladen"),
title: const Text('Lieferung umladen'),
content: RichText(
text: TextSpan(
style: theme.textTheme.bodyMedium,
children: [
TextSpan(text: "${delivery.customer.name} ist aktuell "),
TextSpan(
text: '${customer?.name ?? 'Diese Lieferung'} ist aktuell ',
),
TextSpan(
text: foreignPlate,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(text: " zugeordnet. Möchten Sie diese "
"Lieferung auf "),
const TextSpan(
text: ' zugeordnet. Möchten Sie diese Lieferung auf ',
),
TextSpan(
text: ownPlate,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(text: " umladen?"),
const TextSpan(text: ' umladen?'),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text("Abbrechen"),
child: const Text('Abbrechen'),
),
FilledButton(
// Warnfarbe, da das Umladen eine bestehende Zuordnung
// überschreibt — last write wins. `colorScheme.error` ist
// bewusst hart gewählt, damit der Fahrer den Eingriff in
// fremde Disposition bewusst bestätigt.
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.error,
foregroundColor: theme.colorScheme.onError,
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text("Übernehmen"),
child: const Text('Übernehmen'),
),
],
),
@ -181,16 +155,14 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
if (result != true || !mounted) return;
context.read<TourBloc>().add(
AssignCarEvent(
AssignCarToDelivery(
deliveryId: delivery.id,
carId: _carIdString,
carId: widget.selectedCarId,
),
);
}
// ---------------------------------------------------------------------------
// Widgets
// ---------------------------------------------------------------------------
// ─── Widgets ─────────────────────────────────────────────────────────
Widget _plateBadge(BuildContext context, String plate, {bool own = false}) {
final theme = Theme.of(context);
@ -213,11 +185,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
const SizedBox(width: 4),
Text(
plate,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: fg,
),
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: fg),
),
],
),
@ -257,13 +225,13 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
);
}
Widget _availableTab(List<Delivery> available) {
Widget _availableTab(List<Delivery> available, TourDetails details) {
if (available.isEmpty) {
return _emptyState(
icon: Icons.inbox_outlined,
title: "Alle Lieferungen sind verteilt.",
subtitle: "Im Tab \"Vergeben\" können Sie eigene Lieferungen "
"freigeben oder fremde übernehmen.",
title: 'Alle Lieferungen sind verteilt.',
subtitle: 'Im Tab "Vergeben" können Sie eigene Lieferungen '
'freigeben oder fremde übernehmen.',
);
}
@ -272,27 +240,29 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
itemCount: available.length,
itemBuilder: (context, index) {
final delivery = available[index];
final customer = details.customerOf(delivery);
final isSelected = _selectedIds.contains(delivery.id);
return CheckboxListTile(
key: ValueKey("available-${delivery.id}"),
key: ValueKey('available-${delivery.id}'),
value: isSelected,
onChanged: _isAssigning
? null
: (checked) {
setState(() {
if (checked == true) {
_selectedIds.add(delivery.id);
} else {
_selectedIds.remove(delivery.id);
}
});
},
onChanged: (checked) {
// Während ein Bulk-Zuweisung läuft, blockiert der
// OperationViewEnforcer die Eingabe global; ein separates
// `_isAssigning`-Lock ist hier nicht mehr nötig.
setState(() {
if (checked == true) {
_selectedIds.add(delivery.id);
} else {
_selectedIds.remove(delivery.id);
}
});
},
title: Text(
delivery.customer.name,
customer?.name ?? '⟨Unbekannter Kunde⟩',
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
delivery.customer.address.toString(),
delivery.deliveryAddressSnapshot.oneLine,
style: const TextStyle(fontSize: 12),
),
controlAffinity: ListTileControlAffinity.leading,
@ -301,11 +271,11 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
);
}
Widget _assignedTab(List<Delivery> assigned, Tour tour) {
Widget _assignedTab(List<Delivery> assigned, TourDetails details) {
if (assigned.isEmpty) {
return _emptyState(
icon: Icons.local_shipping_outlined,
title: "Noch keine Lieferungen verteilt.",
title: 'Noch keine Lieferungen verteilt.',
);
}
@ -316,20 +286,21 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
itemCount: assigned.length,
itemBuilder: (context, index) {
final delivery = assigned[index];
final isOwn = delivery.carId == widget.selectedCarId;
final plate = _plateFor(delivery.carId, tour);
final isOwn = delivery.assignedCarId == widget.selectedCarId;
final plate = _plateFor(delivery.assignedCarId);
final customer = details.customerOf(delivery);
return Material(
color: isOwn
? theme.colorScheme.primaryContainer.withValues(alpha: 0.35)
: null,
child: ListTile(
key: ValueKey("assigned-${delivery.id}"),
key: ValueKey('assigned-${delivery.id}'),
onTap: () {
if (isOwn) {
_showReleaseDialog(delivery);
_showReleaseDialog(delivery, details);
} else {
_showTakeoverDialog(delivery, tour);
_showTakeoverDialog(delivery, details);
}
},
leading: Icon(
@ -339,14 +310,14 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
: theme.colorScheme.onSurfaceVariant,
),
title: Text(
delivery.customer.name,
customer?.name ?? '⟨Unbekannter Kunde⟩',
style: TextStyle(
fontWeight: FontWeight.w600,
color: isOwn ? theme.colorScheme.primary : null,
),
),
subtitle: Text(
delivery.customer.address.toString(),
delivery.deliveryAddressSnapshot.oneLine,
style: const TextStyle(fontSize: 12),
),
trailing: _plateBadge(context, plate, own: isOwn),
@ -356,13 +327,12 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
);
}
/// BottomBar mit Bulk-Confirm (kontextabhängig) + Phasen-Wechsel.
/// Confirm-Button ist nur sichtbar, wenn etwas selektiert ist —
/// "Weiter zum Sortieren" bleibt immer sichtbar.
Widget _buildBottomBar() {
final theme = Theme.of(context);
final hasSelection = _selectedIds.isNotEmpty;
// Disabled-State und Spinner während des Bulk-Zuweisens übernimmt
// global der `OperationViewEnforcer` (StartOperation/FinishOperation
// aus dem TourBloc) — daher hier keine lokale Lock-Variable mehr.
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
@ -373,20 +343,10 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed:
_isAssigning ? null : _confirmSelection,
icon: _isAssigning
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: const Icon(Icons.check),
onPressed: _confirmSelection,
icon: const Icon(Icons.check),
label: Text(
"Auswahl bestätigen (${_selectedIds.length})",
'Auswahl bestätigen (${_selectedIds.length})',
),
),
),
@ -394,9 +354,9 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isAssigning ? null : _goToSorting,
onPressed: _goToSorting,
icon: const Icon(Icons.arrow_forward),
label: const Text("Weiter zum Sortieren"),
label: const Text('Weiter zum Sortieren'),
),
),
],
@ -409,24 +369,38 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is TourLoadingFailed) {
if (state is TourLoadFailed) {
return const DeliveryLoadingFailedPage();
}
if (state is TourEmpty) {
return Scaffold(
appBar: AppBar(title: const Text('Lieferungen auswählen')),
body: const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Für heute ist keine Tour zugewiesen.',
style: TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
),
),
);
}
if (state is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final available = state.tour.deliveries
.where((d) => d.carId == null)
final details = state.details;
final available = details.deliveries
.where((d) => d.assignedCarId == null)
.toList();
final assigned = state.tour.deliveries
.where((d) => d.carId != null)
final assigned = details.deliveries
.where((d) => d.assignedCarId != null)
.toList();
// Falls eine selektierte Lieferung in der Zwischenzeit zugeordnet
// wurde (z. B. durch einen parallelen Vorgang), Selektion bereinigen.
_selectedIds.removeWhere(
(id) => !available.any((d) => d.id == id),
);
@ -442,22 +416,20 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
children: [
PhaseStepper(
currentPhase: DeliveryPhase.auswaehlen,
carId: _carIdString,
carId: widget.selectedCarId,
),
Material(
color: Theme.of(context).primaryColor,
child: TabBar(
labelColor:
Theme.of(context).colorScheme.onPrimary,
labelColor: Theme.of(context).colorScheme.onPrimary,
unselectedLabelColor: Theme.of(context)
.colorScheme
.onPrimary
.withValues(alpha: 0.6),
indicatorColor:
Theme.of(context).colorScheme.onPrimary,
indicatorColor: Theme.of(context).colorScheme.onPrimary,
tabs: [
Tab(text: "Verfügbar (${available.length})"),
Tab(text: "Vergeben (${assigned.length})"),
Tab(text: 'Verfügbar (${available.length})'),
Tab(text: 'Vergeben (${assigned.length})'),
],
),
),
@ -466,8 +438,8 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
),
body: TabBarView(
children: [
_availableTab(available),
_assignedTab(assigned, state.tour),
_availableTab(available, details),
_assignedTab(assigned, details),
],
),
bottomNavigationBar: _buildBottomBar(),

View File

@ -1,5 +1,9 @@
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/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
@ -12,9 +16,15 @@ import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
/// Page für die zweite Phase des Lieferprozesses (Sortieren). Der Fahrer
/// legt per Drag&Drop die Reihenfolge fest, ändert lokal so oft er möchte
/// und bestätigt am Ende mit einem expliziten Klick. Erst dann wird die
/// und bestätigt am Ende mit einem expliziten Klick. Dann wird die
/// Reihenfolge ans Backend übertragen und in Phase [DeliveryPhase.beladen]
/// gewechselt.
///
/// Hinweis zum Backend-Endpoint:
/// `PUT /tours/{id}/delivery-order` erwartet die **vollständige** Reihenfolge
/// aller Lieferungen der Tour. Bei Mehr-Auto-Teams sortiert der Fahrer
/// trotzdem nur „seine" Lieferungen — die Reihenfolge der fremden Lieferungen
/// wird unverändert anhand der aktuellen `sortOrder` mitgeschickt.
class DeliverySortPage extends StatefulWidget {
const DeliverySortPage({
super.key,
@ -23,9 +33,6 @@ class DeliverySortPage extends StatefulWidget {
});
final String selectedCarId;
/// Optionaler Hook, damit die übergeordnete Routing-Stelle nach Erfolg
/// auf die nächste Phase wechseln kann (rerender der Beladungs-Page).
final VoidCallback? onPhaseAdvanced;
@override
@ -33,82 +40,88 @@ class DeliverySortPage extends StatefulWidget {
}
class _DeliverySortPageState extends State<DeliverySortPage> {
late final SortableDeliveryListController _listController;
final SortableDeliveryListController _listController =
SortableDeliveryListController();
/// Verhindert mehrfache SnackBars für denselben Fehler-State.
String? _lastShownErrorSignature;
/// Letzter Tour-"Fingerabdruck" (Anzahl + erste/letzte ID), zu dem wir den
/// Sortier-Bucket des aktuellen Autos bereits konsistent gemacht haben.
/// Verhindert unnötige Event-Stürme, wenn der TourBloc häufig rebuildet.
String? _lastEnsuredTourSignature;
/// True, sobald `_confirm` gefeuert wurde — wir nutzen das, um den
/// Übergang persisting → fertig zuverlässig zu erkennen.
bool _isAwaitingConfirm = false;
/// Trackt, ob im letzten Listener-Tick `isPersistingSorting` true war —
/// damit wir den Übergang persisting → fertig zuverlässig erkennen,
/// auch wenn der Listener für Bucket-Maintenance bereits zwischendurch
/// gefeuert hat.
bool _wasPersisting = false;
@override
void initState() {
super.initState();
_listController = SortableDeliveryListController();
// Falls Tour bereits geladen ist: Sortier-Bucket sofort konsistent
// machen. Sonst übernimmt das der BlocConsumer-Listener beim ersten
// TourLoaded.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_ensureBucketIfNeeded(context.read<TourBloc>().state);
});
bool _multiCarTeam(BuildContext context) {
final carsState = context.read<CarsBloc>().state;
return carsState is CarsLoaded && carsState.cars.length >= 2;
}
String _tourSignature(TourLoaded state) {
final ids = state.tour.deliveries.map((d) => d.id).toList();
if (ids.isEmpty) return "0";
return "${ids.length}|${ids.first}|${ids.last}";
List<Delivery> _ownDeliveries(TourDetails details) {
if (_multiCarTeam(context)) {
return details.deliveriesSorted
.where((d) => d.assignedCarId == widget.selectedCarId)
.toList();
}
return details.deliveriesSorted;
}
void _ensureBucketIfNeeded(TourState state) {
if (state is! TourLoaded) return;
final signature = _tourSignature(state);
if (signature == _lastEnsuredTourSignature) return;
_lastEnsuredTourSignature = signature;
context.read<TourBloc>().add(
EnsureSortingForCarEvent(carId: widget.selectedCarId.toString()),
);
/// Baut die vollständige Tour-Reihenfolge: die fremden Lieferungen bleiben
/// in ihrer aktuellen `sortOrder`, die eigenen werden in der vom Fahrer
/// gewählten Reihenfolge eingefügt — an den Positionen, an denen sie
/// vorher waren. Beispiel: hat der Fahrer „eigene" Plätze 1, 3, 5
/// belegt und sortiert lokal um, dann landen seine neuen Reihen-Ids
/// in derselben Reihenfolge auf den Positionen 1, 3, 5; fremde Items
/// behalten ihre Plätze 2, 4.
List<String> _buildFullTourOrder(
TourDetails details,
List<String> ownOrderedIds,
) {
final sorted = details.deliveriesSorted;
if (!_multiCarTeam(context)) return ownOrderedIds;
final ownSlotsByPosition = <int, String>{};
final foreignByPosition = <int, String>{};
for (var i = 0; i < sorted.length; i++) {
final d = sorted[i];
if (d.assignedCarId == widget.selectedCarId) {
ownSlotsByPosition[i] = d.id;
} else {
foreignByPosition[i] = d.id;
}
}
final ownPositions = ownSlotsByPosition.keys.toList()..sort();
final result = List<String?>.filled(sorted.length, null);
for (final entry in foreignByPosition.entries) {
result[entry.key] = entry.value;
}
for (var i = 0; i < ownPositions.length && i < ownOrderedIds.length; i++) {
result[ownPositions[i]] = ownOrderedIds[i];
}
// Schließe verbleibende Lücken auf — sollte normalerweise leer sein.
return result.whereType<String>().toList();
}
List<String> _orderedIdsFor(TourLoaded state) {
return state.sortingInformation[widget.selectedCarId.toString()] ??
const <String>[];
}
/// Phase im zentralen [PhaseBloc] auf "beladen" setzen. Der Bloc kümmert
/// sich um Persistenz via [PhaseService]. Der optionale UI-Callback wird
/// (aus Rückwärtskompatibilität) zusätzlich gefeuert.
void _advanceToLoading() {
context.read<PhaseBloc>().add(
PhaseSet(
carId: widget.selectedCarId.toString(),
carId: widget.selectedCarId,
phase: DeliveryPhase.beladen,
),
);
widget.onPhaseAdvanced?.call();
}
void _skipEmptyToLoading() => _advanceToLoading();
void _confirm() {
// _wasPersisting hier setzen — der erste Listener-Tick mit
// isPersistingSorting=true wird von listenWhen herausgefiltert
// (kein "phaseEnded"-Übergang), daher müssen wir das Flag vorher
// selbst hochziehen. Sonst erkennt der Listener beim zweiten Tick
// (isPersistingSorting=false) den Übergang nicht und der
// Phasen-Wechsel auf "beladen" bleibt aus.
_wasPersisting = true;
void _confirm(TourDetails details) {
final ownOrder = _listController.readCurrentOrder();
final fullOrder = _buildFullTourOrder(details, ownOrder);
if (fullOrder.isEmpty) {
_advanceToLoading();
return;
}
_isAwaitingConfirm = true;
context.read<TourBloc>().add(
ConfirmSortingEvent(carId: widget.selectedCarId.toString()),
);
ReorderDeliveries(orderedDeliveryIds: fullOrder),
);
}
Widget _hintCard({required IconData icon, required String text, Color? color}) {
@ -133,14 +146,14 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
const Icon(Icons.inbox_outlined, size: 64, color: Colors.grey),
const SizedBox(height: 12),
Text(
"Keine Lieferungen heute",
'Keine Lieferungen heute',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: Text(
"Für das ausgewählte Fahrzeug sind heute keine Lieferungen geplant.",
'Für das ausgewählte Fahrzeug sind heute keine Lieferungen geplant.',
textAlign: TextAlign.center,
),
),
@ -148,33 +161,29 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
);
}
Widget _singleDeliveryHint(String singleId, TourLoaded state) {
final delivery = state.tour.deliveries.firstWhere(
(d) => d.id == singleId,
orElse: () => state.tour.deliveries.first,
);
Widget _singleDeliveryHint(Delivery single, TourDetails details) {
final customer = details.customerOf(single);
return Column(
children: [
_hintCard(
icon: Icons.info_outline,
text:
"Nur eine Lieferung — die Reihenfolge ist trivial. "
"Tippen Sie auf \"Weiter zur Beladung\", um fortzufahren.",
text: 'Nur eine Lieferung — die Reihenfolge ist trivial. '
'Tippen Sie auf "Weiter zur Beladung", um fortzufahren.',
),
const Divider(),
ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
child: Text(
"1",
'1',
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
),
),
),
title: Text(delivery.customer.name),
title: Text(customer?.name ?? '⟨Unbekannter Kunde⟩'),
subtitle: Text(
delivery.customer.address.toString(),
single.deliveryAddressSnapshot.oneLine,
style: const TextStyle(fontSize: 11),
),
),
@ -186,55 +195,48 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
Widget build(BuildContext context) {
return BlocConsumer<TourBloc, TourState>(
listenWhen: (prev, curr) {
// Bucket-Maintenance: erstes TourLoaded oder Tour-Inhalt geändert.
final bucketTrigger = (prev is! TourLoaded && curr is TourLoaded) ||
(prev is TourLoaded &&
curr is TourLoaded &&
prev.tour.deliveries.length != curr.tour.deliveries.length);
if (bucketTrigger) return true;
if (prev is! TourLoaded || curr is! TourLoaded) return false;
final phaseEnded =
prev.isPersistingSorting && !curr.isPersistingSorting;
final newError =
curr.sortingPersistError != null &&
curr.sortingPersistError != prev.sortingPersistError;
prev.isPersistingReorder && !curr.isPersistingReorder;
final newError = curr.reorderError != null &&
curr.reorderError != prev.reorderError;
return phaseEnded || newError;
},
listener: (context, state) {
if (state is! TourLoaded) return;
// 1) Bucket-Konsistenz nachziehen, falls die Tour gerade erst geladen
// wurde oder sich verändert hat.
_ensureBucketIfNeeded(state);
// 2) Fehler-SnackBar für Confirm-Fehler.
final err = state.sortingPersistError;
final err = state.reorderError;
if (err != null && err != _lastShownErrorSignature) {
_lastShownErrorSignature = err;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err)),
);
_wasPersisting = state.isPersistingSorting;
_isAwaitingConfirm = false;
return;
}
// 3) Echter Übergang persisting → fertig + kein Fehler → erfolgreich
// bestätigt → nächste Phase. Übergang über das Feld _wasPersisting
// erkannt, da der Listener auch bei Bucket-Triggern feuert.
if (_wasPersisting && !state.isPersistingSorting && err == null) {
if (_isAwaitingConfirm &&
!state.isPersistingReorder &&
err == null) {
_isAwaitingConfirm = false;
_advanceToLoading();
}
_wasPersisting = state.isPersistingSorting;
},
builder: (context, state) {
if (state is TourEmpty) {
return Scaffold(
appBar: AppBar(title: const Text('Sortieren')),
body: _emptyState(),
);
}
if (state is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final orderedIds = _orderedIdsFor(state);
final details = state.details;
final ownDeliveries = _ownDeliveries(details);
return Scaffold(
drawer: const HomeAppDrawer(),
@ -242,40 +244,41 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
preferredSize: const Size.fromHeight(140),
child: PhaseStepper(
currentPhase: DeliveryPhase.sortieren,
carId: widget.selectedCarId.toString(),
carId: widget.selectedCarId,
),
),
body: SafeArea(
child: _buildBody(state, orderedIds),
),
bottomNavigationBar: _buildBottomBar(state, orderedIds),
body: SafeArea(child: _buildBody(state, details, ownDeliveries)),
bottomNavigationBar:
_buildBottomBar(state, details, ownDeliveries),
);
},
);
}
Widget _buildBody(TourLoaded state, List<String> orderedIds) {
if (orderedIds.isEmpty) {
Widget _buildBody(
TourLoaded state,
TourDetails details,
List<Delivery> ownDeliveries,
) {
if (ownDeliveries.isEmpty) {
return _emptyState();
}
if (orderedIds.length == 1) {
return _singleDeliveryHint(orderedIds.first, state);
if (ownDeliveries.length == 1) {
return _singleDeliveryHint(ownDeliveries.first, details);
}
return Column(
children: [
_hintCard(
icon: Icons.info_outline,
text:
"Ziehen Sie die einzelnen Lieferungen mit dem Finger in die "
"gewünschte Position. Die Reihenfolge wird erst beim "
"Bestätigen ans System übertragen.",
text: 'Ziehen Sie die einzelnen Lieferungen mit dem Finger in die '
'gewünschte Position. Die Reihenfolge wird erst beim '
'Bestätigen ans System übertragen.',
),
const Divider(height: 1),
Expanded(
child: SortableDeliveryList(
selectedCarId: widget.selectedCarId,
details: details,
deliveries: ownDeliveries,
controller: _listController,
),
),
@ -283,35 +286,38 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
);
}
Widget _buildBottomBar(TourLoaded state, List<String> orderedIds) {
final isLoading = state.isPersistingSorting;
Widget _buildBottomBar(
TourLoaded state,
TourDetails details,
List<Delivery> ownDeliveries,
) {
final isLoading = state.isPersistingReorder;
final theme = Theme.of(context);
// Spezialfälle: 0 / 1 Lieferungen → vereinfachte BottomBar
if (orderedIds.isEmpty) {
if (ownDeliveries.isEmpty) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: isLoading ? null : _skipEmptyToLoading,
onPressed: isLoading ? null : _advanceToLoading,
icon: const Icon(Icons.arrow_forward),
label: const Text("Weiter zur Beladung"),
label: const Text('Weiter zur Beladung'),
),
),
),
);
}
if (orderedIds.length == 1) {
if (ownDeliveries.length == 1) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: isLoading ? null : _confirm,
onPressed: isLoading ? null : () => _confirm(details),
icon: isLoading
? const SizedBox(
width: 16,
@ -322,7 +328,7 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
),
)
: const Icon(Icons.arrow_forward),
label: const Text("Weiter zur Beladung"),
label: const Text('Weiter zur Beladung'),
),
),
),
@ -336,18 +342,16 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: isLoading
? null
: () => _listController.resetToDefault(),
onPressed: isLoading ? null : _listController.resetToDefault,
icon: const Icon(Icons.restart_alt),
label: const Text("Zurücksetzen"),
label: const Text('Zurücksetzen'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: FilledButton.icon(
onPressed: isLoading ? null : _confirm,
onPressed: isLoading ? null : () => _confirm(details),
icon: isLoading
? SizedBox(
width: 16,
@ -358,7 +362,7 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
),
)
: const Icon(Icons.check),
label: const Text("Reihenfolge bestätigen"),
label: const Text('Reihenfolge bestätigen'),
),
),
],

View File

@ -1,82 +0,0 @@
import 'package:flutter/cupertino.dart';
import 'package:geolocator/geolocator.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class DistanceService {
static const String GOOGLE_MAPS_API_KEY = 'DEIN_API_KEY_HIER';
static Future<Position> getCurrentLocation() async {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
throw Exception('Location services sind deaktiviert');
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
return await Geolocator.getCurrentPosition();
}
// Adresse in Koordinaten umwandeln (Geocoding)
static Future<Map<String, double>> getCoordinates(String address) async {
String url =
'https://maps.googleapis.com/maps/api/geocode/json'
'?address=${Uri.encodeComponent(address)}'
'&key=AIzaSyB5_1ftLnoswoy59FzNFkrQ7SSDma5eu5E';
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
var json = jsonDecode(response.body);
if (json['results'].isNotEmpty) {
var location = json['results'][0]['geometry']['location'];
return {
'lat': location['lat'],
'lng': location['lng'],
};
}
throw Exception('Adresse nicht gefunden');
}
throw Exception('Geocoding Fehler: ${response.statusCode}');
}
// Distanz berechnen
static Future<double> getDistanceByRoad(String address) async {
try {
Position currentPos = await getCurrentLocation();
Map<String, double> coords = await getCoordinates(address);
String origin = "${currentPos.latitude},${currentPos.longitude}";
String destination = "${coords['lat']},${coords['lng']}";
String url =
'https://maps.googleapis.com/maps/api/distancematrix/json'
'?origins=$origin'
'&destinations=$destination'
'&key=AIzaSyB5_1ftLnoswoy59FzNFkrQ7SSDma5eu5E';
final response = await http.get(Uri.parse(url));
debugPrint(response.body);
if (response.statusCode == 200) {
var json = jsonDecode(response.body);
if (json['rows'][0]['elements'][0]['status'] == 'OK') {
int distanceMeters = json['rows'][0]['elements'][0]['distance']['value'];
return distanceMeters / 1000; // In km
} else {
throw Exception('Route nicht gefunden');
}
} else {
throw Exception('API Fehler: ${response.statusCode}');
}
} catch (e) {
throw Exception('Fehler: $e');
}
}
}

View File

@ -1,57 +1,52 @@
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Persistiert die aktuelle Phase pro Fahrzeug. Der Key ist datumsspezifisch,
/// damit ein App-Neustart am nächsten Tag automatisch wieder mit Phase 1
/// (Sortieren) startet — die Phase eines Vortags hat keine Bedeutung mehr.
/// Persistiert die aktuelle Phase pro Fahrzeug.
///
/// Zusätzlich wird die **höchste am Tag erreichte Phase** pro Fahrzeug
/// persistiert (eigener Key-Suffix `_max`). Der Stepper nutzt diesen Wert,
/// um Vorwärts-Sprünge auf bereits besuchte Phasen zu erlauben — auch wenn
/// der Fahrer zwischenzeitlich zurückgesprungen ist.
/// Der Key ist an einen **Tour-Token** gebunden (abgeleitet aus
/// `Tour.syncedAt`) statt nur an das Datum. Vorteile:
///
/// * Ein erneuter ERP-Sync / Demo-Seed schreibt eine neue `syncedAt` → neuer
/// Token → die Phasen (inkl. der „erledigt"-Häkchen im Stepper) starten
/// frisch. So bleibt ein „Daten-Reset" im Backend nicht an alten lokalen
/// Häkchen hängen.
/// * Eine Tour von heute hat heutiges `syncedAt` — die Tagesbindung ist
/// damit implizit (am nächsten Tag gibt es ohnehin eine neue Tour).
/// * Bloßes Weiterscannen (Item-Status) ändert `syncedAt` nicht → der
/// Fahrer-Fortschritt bleibt über App-Neustarts derselben Tour erhalten.
///
/// Zusätzlich wird die **höchste erreichte Phase** pro Fahrzeug persistiert
/// (Key-Suffix `_max`). Der Stepper nutzt das, um Vorwärts-Sprünge auf
/// bereits besuchte Phasen zu erlauben — auch nach einem Rücksprung.
class PhaseService {
static const _prefix = "delivery_phase";
String _key(String carId) {
final now = DateTime.now();
final date = "${now.year}_${now.month}_${now.day}";
return "${_prefix}_${date}_$carId";
}
String _key(String carId, String token) => "${_prefix}_${token}_$carId";
String _maxKey(String carId) {
final now = DateTime.now();
final date = "${now.year}_${now.month}_${now.day}";
return "${_prefix}_max_${date}_$carId";
}
String _maxKey(String carId, String token) =>
"${_prefix}_max_${token}_$carId";
Future<void> save(String carId, DeliveryPhase phase) async {
Future<void> save(String carId, String token, DeliveryPhase phase) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key(carId), phase.persistenceKey);
await prefs.setString(_key(carId, token), phase.persistenceKey);
}
Future<DeliveryPhase?> load(String carId) async {
final prefs = await SharedPreferences.getInstance();
return DeliveryPhaseExtension.fromPersistenceKey(prefs.getString(_key(carId)));
}
Future<void> saveMax(String carId, DeliveryPhase phase) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_maxKey(carId), phase.persistenceKey);
}
Future<DeliveryPhase?> loadMax(String carId) async {
Future<DeliveryPhase?> load(String carId, String token) async {
final prefs = await SharedPreferences.getInstance();
return DeliveryPhaseExtension.fromPersistenceKey(
prefs.getString(_maxKey(carId)),
prefs.getString(_key(carId, token)),
);
}
Future<Map<String, DeliveryPhase>> loadAll(Iterable<String> carIds) async {
final result = <String, DeliveryPhase>{};
for (final carId in carIds) {
final phase = await load(carId);
if (phase != null) result[carId] = phase;
}
return result;
Future<void> saveMax(String carId, String token, DeliveryPhase phase) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_maxKey(carId, token), phase.persistenceKey);
}
Future<DeliveryPhase?> loadMax(String carId, String token) async {
final prefs = await SharedPreferences.getInstance();
return DeliveryPhaseExtension.fromPersistenceKey(
prefs.getString(_maxKey(carId, token)),
);
}
}

View File

@ -1,74 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:path_provider/path_provider.dart';
class ReorderService {
get _path async {
final dir = await getApplicationDocumentsDirectory();
final date = DateTime.now();
final filename = "custom_sort_${date.year}_${date.month}_${date.day}.json";
final path = "${dir.path}/$filename";
return path;
}
Future<File> get _file async {
final path = await _path;
final file = File(path);
return file;
}
Future<void> saveSortingInformation(
Map<String, List<String>> container,
) async {
debugPrint("CONTAINER: ${jsonEncode(container)}");
(await _file).writeAsString(jsonEncode(container));
}
Future<void> initializeTour(Tour tour) async {
(await _file).create();
Map<String, List<String>> sorting = {};
for (final delivery in tour.deliveries) {
if (!sorting.containsKey(delivery.carId.toString())) {
sorting[delivery.carId.toString()] = [delivery.id];
} else {
sorting[delivery.carId.toString()]!.add(delivery.id);
}
}
(await _file).writeAsString(jsonEncode({"cars": sorting}));
}
bool orderInformationExist() {
return false;
}
Future<Map<String, List<String>>> loadSortingInformation() async {
debugPrint("FILE: ${await (await _file).readAsString()}");
Map<String, List<String>> container = {};
Map<String, dynamic> json = jsonDecode(await (await _file).readAsString());
if (!json.containsKey("cars")) {
throw Exception("No cars found in file");
}
for (final car in json["cars"].entries) {
List<String> values = [];
for (String value in car.value) {
values.add(value);
}
container[car.key] = values;
}
return container;
}
}

View File

@ -1,30 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/util.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
/// Drag&Drop-Liste der heutigen Lieferungen eines Fahrzeugs.
///
/// Hält die aktuell sichtbare Reihenfolge in lokalem State, damit das
/// Verschieben spürbar unmittelbar wirkt — und gibt jeden Drop zusätzlich
/// als [ReorderDeliveryEvent] an den [TourBloc] weiter. Dort übernimmt
/// der [ReorderService] die lokale Persistenz. Es findet hier bewusst
/// **kein** API-Call statt; der Backend-Sync läuft erst, wenn der Fahrer
/// die Reihenfolge in der übergeordneten Page bestätigt.
/// Hält die aktuell sichtbare Reihenfolge in lokalem State; ein Tour-Reload
/// von außen würde sie überschreiben — das ist Absicht, damit der Backend-
/// Stand bei Pull-to-refresh durchschlägt. Der eigentliche Backend-Sync
/// erfolgt erst, wenn der Fahrer in der übergeordneten Page bestätigt.
class SortableDeliveryList extends StatefulWidget {
const SortableDeliveryList({
super.key,
required this.selectedCarId,
required this.details,
required this.deliveries,
this.controller,
});
final String? selectedCarId;
/// Aggregat-Snapshot — wird für Kunden-Lookup gebraucht.
final TourDetails details;
/// Optionaler Controller zum Zurücksetzen der Liste durch Eltern-Widgets
/// (z. B. Button "Zurücksetzen" in der Page).
/// Die in der Liste anzuzeigenden Lieferungen, vorgefiltert vom Aufrufer
/// (z. B. nur die dem ausgewählten Fahrzeug zugewiesenen).
final List<Delivery> deliveries;
/// Optionaler Controller zum Auslesen der aktuellen Reihenfolge und zum
/// Zurücksetzen durch Eltern-Widgets.
final SortableDeliveryListController? controller;
@override
@ -32,12 +32,12 @@ class SortableDeliveryList extends StatefulWidget {
}
class _SortableDeliveryListState extends State<SortableDeliveryList> {
late List<String> _localSortedList;
late List<String> _orderedIds;
@override
void initState() {
super.initState();
_localSortedList = _readSortedListFromBloc();
_orderedIds = widget.deliveries.map((d) => d.id).toList(growable: true);
widget.controller?._attach(this);
}
@ -48,6 +48,17 @@ class _SortableDeliveryListState extends State<SortableDeliveryList> {
oldWidget.controller?._detach(this);
widget.controller?._attach(this);
}
// Wenn sich die Eingangsliste fundamental ändert (Tour-Reload, neue
// Lieferung hinzu/weg), local-state neu synchronisieren.
final incomingIds = widget.deliveries.map((d) => d.id).toSet();
final localIds = _orderedIds.toSet();
if (incomingIds.length != localIds.length ||
!incomingIds.containsAll(localIds)) {
setState(() {
_orderedIds =
widget.deliveries.map((d) => d.id).toList(growable: true);
});
}
}
@override
@ -56,115 +67,64 @@ class _SortableDeliveryListState extends State<SortableDeliveryList> {
super.dispose();
}
List<String> _readSortedListFromBloc() {
final state = context.read<TourBloc>().state;
if (state is TourLoaded) {
return [
...state.sortingInformation[widget.selectedCarId.toString()] ?? [],
];
}
return [];
}
/// Setzt die Liste auf die natürliche Reihenfolge zurück, in der die
/// Lieferungen in der Tour stehen. Wird vom Controller (Button
/// "Zurücksetzen") aufgerufen und meldet jeden notwendigen Swap als
/// Reorder-Event, damit der lokale Persistenz-State synchron bleibt.
///
/// Filterlogik (muss konsistent zu `_ensureSortingForCar` im TourBloc
/// sein):
/// * Ein-Auto-Teams: alle Tour-Lieferungen.
/// * Mehr-Auto-Teams: nur Lieferungen, die dem ausgewählten Fahrzeug
/// nach der Auswahl bereits zugeordnet sind.
void _resetToDefault() {
final state = context.read<TourBloc>().state;
if (state is! TourLoaded) return;
final cars = state.tour.driver.cars;
final carIdStr = widget.selectedCarId.toString();
final List<String> defaultOrder = cars.length >= 2
? state.tour.deliveries
.where((d) => d.carId?.toString() == carIdStr)
.map((d) => d.id)
.toList()
: state.tour.deliveries.map((d) => d.id).toList();
setState(() {
_localSortedList = [...defaultOrder];
_orderedIds =
widget.deliveries.map((d) => d.id).toList(growable: true);
});
final container = {
...state.sortingInformation,
carIdStr: [...defaultOrder],
};
context.read<TourBloc>().add(
ReplaceSortingEvent(
carId: carIdStr,
newSortingInformation: container,
),
);
}
List<String> _readCurrentOrder() => List<String>.of(_orderedIds);
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is! TourLoaded) {
return const Center(child: CircularProgressIndicator());
}
final byId = {for (final d in widget.deliveries) d.id: d};
return ReorderableListView(
buildDefaultDragHandles: true,
onReorder: (oldIndex, newIndex) {
setState(() {
_localSortedList = reorderList(
_localSortedList,
oldIndex,
newIndex,
);
});
context.read<TourBloc>().add(
ReorderDeliveryEvent(
newPosition: newIndex,
oldPosition: oldIndex,
carId: widget.selectedCarId.toString(),
),
);
},
children: _localSortedList.map((id) {
final Delivery delivery = state.tour.deliveries.firstWhere(
(delivery) => delivery.id == id,
);
final int pos = _localSortedList.indexOf(id) + 1;
return ListTile(
key: Key("reorder-item-${delivery.id}"),
leading: CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
child: Text(
"$pos",
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
),
),
),
title: Text(delivery.customer.name),
subtitle: Text(
delivery.customer.address.toString(),
style: const TextStyle(fontSize: 11),
),
trailing: const Icon(Icons.drag_handle),
);
}).toList(),
);
return ReorderableListView(
buildDefaultDragHandles: true,
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex -= 1;
final id = _orderedIds.removeAt(oldIndex);
_orderedIds.insert(newIndex, id);
});
},
children: _orderedIds.asMap().entries.map((entry) {
final id = entry.value;
final pos = entry.key + 1;
final delivery = byId[id];
if (delivery == null) {
return ListTile(
key: Key('reorder-item-orphan-$id'),
title: Text('Lieferung $id nicht mehr in der Tour'),
);
}
final customer = widget.details.customerOf(delivery);
return ListTile(
key: Key('reorder-item-${delivery.id}'),
leading: CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
child: Text(
'$pos',
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
),
),
),
title: Text(customer?.name ?? '⟨Unbekannter Kunde⟩'),
subtitle: Text(
delivery.deliveryAddressSnapshot.oneLine,
style: const TextStyle(fontSize: 11),
),
trailing: const Icon(Icons.drag_handle),
);
}).toList(),
);
}
}
/// Schmaler Controller, mit dem Eltern-Widgets die Liste zurücksetzen
/// können, ohne den internen State direkt anzufassen.
/// und die aktuelle Reihenfolge auslesen können.
class SortableDeliveryListController {
_SortableDeliveryListState? _state;
@ -173,6 +133,11 @@ class SortableDeliveryListController {
if (_state == state) _state = null;
}
/// Setzt die Liste auf die Default-Reihenfolge (Tour-Reihenfolge) zurück.
/// Setzt die Liste auf die vom Aufrufer übergebene Default-Reihenfolge
/// zurück (= aktueller `widget.deliveries`-Stand).
void resetToDefault() => _state?._resetToDefault();
/// Aktuelle Reihenfolge der Delivery-IDs, wie sie der Fahrer sieht.
List<String> readCurrentOrder() =>
_state?._readCurrentOrder() ?? const <String>[];
}

View File

@ -0,0 +1,457 @@
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/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';
import 'package:hl_lieferservice/widget/scanner/article_scanner_stripe.dart';
import 'package:hl_lieferservice/widget/scanner/item_matcher.dart';
import 'package:hl_lieferservice/widget/scanner/manual_entry_dialog.dart';
import 'package:hl_lieferservice/widget/scanner/scan_code_parser.dart';
/// Scan-Screen für die Filial-Abholung in der Auslieferungs-Phase.
///
/// Der Fahrer hat in der Beladen-Phase das Standardlager beladen. Artikel
/// aus einer Filiale waren noch offen — bevor er ausliefert, muss er zur
/// Filiale, die Ware holen und hier abscannen. Diese Page wird beim Tap auf
/// eine Lieferung mit offenen Filial-Artikeln geöffnet (siehe
/// `DeliveryOverview`).
///
/// Fokus auf **eine** Lieferung + **nur** deren Filial-Items. Gleiche
/// QR-Validierung (`Artikelnr;Kundennr;Belegnr`) und derselbe `ScanItem`-
/// Pfad wie die Beladen-Phase — über die geteilten Scanner-Module.
///
/// Nach vollständigem Scan: Erfolgs-Zustand + Button „zurück zur Übersicht".
/// Der Fahrer fährt dann zum Kunden; dort öffnet ein erneuter Tap die
/// eigentliche Auslieferung (`DeliveryDetail`).
class FilialePickupScanPage extends StatefulWidget {
const FilialePickupScanPage({super.key, required this.deliveryId});
final String deliveryId;
@override
State<FilialePickupScanPage> createState() => _FilialePickupScanPageState();
}
class _FilialePickupScanPageState extends State<FilialePickupScanPage> {
static const String _notIntendedMessage =
'Dieser Artikel gehört nicht zu dieser Filial-Abholung';
String _carId(BuildContext context) {
final state = context.read<CarSelectBloc>().state;
if (state is CarSelectComplete) return state.selectedCar.id;
return '00000000-0000-0000-0000-000000000000';
}
void _showSnackbar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
);
}
/// Nur scanbare, nicht-entfernte Filial-Items dieser Lieferung,
/// aufsteigend nach Belegzeile.
List<DeliveryItem> _externalItems(Delivery delivery, TourDetails details) {
final items = delivery.items.where((it) {
if (it.isRemoved) return false;
if (!details.isArticleScannable(it.articleId)) return false;
final w = details.warehouseOf(it.warehouseId);
return w != null && !w.isStandard;
}).toList()
// Oberartikel vor seinen Komponenten (für eingerückte Darstellung).
..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 items;
}
void _onBarcode({
required String code,
required Delivery delivery,
required TourDetails details,
}) {
final customer = details.customerOf(delivery);
final parsed = parseScanCode(code);
if (parsed == null ||
customer?.erpCustomerId != parsed.customerErpId ||
delivery.erpBelegnummer != parsed.beleg) {
_showSnackbar(_notIntendedMessage);
return;
}
final match = matchItem(
delivery: delivery,
details: details,
articleNumber: parsed.articleNumber,
// Nur Filial-Items zählen — ein Standardlager-Artikel hat hier nichts
// zu suchen (der ist längst auf dem LKW).
itemFilter: (it) {
final w = details.warehouseOf(it.warehouseId);
return w != null && !w.isStandard;
},
);
switch (match) {
case ItemMatchOk(:final item):
context
.read<TourBloc>()
.add(ScanItem(deliveryItemId: item.id, actorCarId: _carId(context)));
case ItemMatchNotInDelivery():
_showSnackbar(_notIntendedMessage);
case ItemMatchNotScannable():
_showSnackbar('Diese Position ist nicht zum Scannen vorgesehen.');
case ItemMatchAllDone():
_showSnackbar('Dieser Artikel ist bereits geladen.');
case ItemMatchAllRemoved():
_showSnackbar('Diese Position wurde aus der Lieferung entfernt.');
case ItemMatchNotOpen():
_showSnackbar('Diese Position ist nicht (mehr) offen.');
}
}
Future<void> _onManualEntry({
required Delivery delivery,
required TourDetails details,
}) async {
final code = await showManualEntryDialog(context);
if (code == null || code.isEmpty || !mounted) return;
_onBarcode(code: code, delivery: delivery, details: details);
}
/// Fallback ohne Barcode: die ganze Restmenge der Filial-Position manuell
/// als geholt bestätigen. Bewusste Aussage → Bestätigungs-Dialog; das
/// Backend protokolliert den Scan als `manual`.
Future<void> _onManualConfirm(DeliveryItem item) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Manuell bestätigen'),
content: const Text(
'Diese Position ohne Scan als aus der Filiale geholt markieren? '
'Das wird als manuelle Bestätigung protokolliert.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Als geholt bestätigen'),
),
],
),
);
if (confirmed != true || !mounted) return;
context.read<TourBloc>().add(ScanItem(
deliveryItemId: item.id,
actorCarId: _carId(context),
manual: true,
));
}
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final details = state.details;
final delivery = _findDelivery(details);
if (delivery == null) {
return Scaffold(
appBar: AppBar(title: const Text('Filial-Abholung')),
body: const Center(
child: Text('Lieferung nicht in der Tour gefunden.'),
),
);
}
final customer = details.customerOf(delivery);
final externalItems = _externalItems(delivery, details);
final doneCount = externalItems.where((it) => it.isDone).length;
final allDone = externalItems.isNotEmpty && doneCount == externalItems.length;
final warehouseNames = details.externalWarehouseLabels(delivery);
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
title: Text(
warehouseNames.isEmpty
? 'Filial-Abholung'
: 'Abholung: ${warehouseNames.join(", ")}',
),
),
body: Column(
children: [
ArticleScannerStripe(
onBarcode: (code) =>
_onBarcode(code: code, delivery: delivery, details: details),
onManualEntry: () =>
_onManualEntry(delivery: delivery, details: details),
),
Expanded(
child: ListView(
// Wenn alle Artikel gescannt sind, deckt die _DoneBar den
// Inset ab; vorher endet die Liste unten frei — daher hier
// die System-Navigationsleiste freihalten.
padding: EdgeInsets.fromLTRB(
16,
12,
16,
24 + (allDone ? 0 : MediaQuery.viewPaddingOf(context).bottom),
),
children: [
_Header(
customerName: customer?.name ?? '⟨Unbekannter Kunde⟩',
belegnummer: delivery.erpBelegnummer,
doneCount: doneCount,
total: externalItems.length,
),
const SizedBox(height: 12),
for (final item in externalItems)
_ExternalItemRow(
item: item,
details: details,
onManualConfirm: () => _onManualConfirm(item),
),
],
),
),
if (allDone) _DoneBar(onConfirm: () => Navigator.of(context).pop()),
],
),
);
},
);
}
Delivery? _findDelivery(TourDetails details) {
for (final d in details.deliveries) {
if (d.id == widget.deliveryId) return d;
}
return null;
}
}
class _Header extends StatelessWidget {
const _Header({
required this.customerName,
required this.belegnummer,
required this.doneCount,
required this.total,
});
final String customerName;
final String belegnummer;
final int doneCount;
final int total;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warehouse_outlined, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
'Artikel aus der Filiale holen & scannen',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
Text(
'$doneCount / $total',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: doneCount == total
? Colors.green.shade700
: theme.colorScheme.primary,
),
),
],
),
const SizedBox(height: 8),
Text(
'$customerName · Beleg-Nr. $belegnummer',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: total == 0 ? 0 : doneCount / total,
minHeight: 6,
backgroundColor: theme.colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
doneCount == total
? Colors.green.shade600
: theme.colorScheme.primary,
),
),
),
],
),
),
);
}
}
class _ExternalItemRow extends StatelessWidget {
const _ExternalItemRow({
required this.item,
required this.details,
required this.onManualConfirm,
});
final DeliveryItem item;
final TourDetails details;
final VoidCallback onManualConfirm;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final article = details.articleOf(item.articleId);
final warehouse = details.warehouseOf(item.warehouseId);
final done = item.isDone;
// Manueller Fallback nur für offene Positionen. Liste ist bereits
// scanbar + extern + nicht-entfernt gefiltert; hier nur done/held raus.
final canManualConfirm = !done && !item.isHeld;
return Card(
// Komponenten eingerückt → gehören zum Oberartikel darüber.
margin: EdgeInsets.only(top: 8, left: item.isComponent ? 24 : 0),
elevation: 0,
color: done
? Colors.green.withValues(alpha: 0.08)
: theme.colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: done
? Colors.green.withValues(alpha: 0.4)
: Colors.transparent,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
done ? Icons.check_circle : Icons.radio_button_unchecked,
color: done
? Colors.green.shade600
: theme.colorScheme.onSurfaceVariant,
),
title: Text(
'${item.isComponent ? '' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
[
article?.articleNumber ?? item.articleId,
if (warehouse != null) warehouse.name,
].join(' · '),
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
trailing: Text(
'${item.scanProgress.scannedQuantity} / ${item.requiredQuantity}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: done ? Colors.green.shade700 : theme.colorScheme.onSurface,
),
),
),
if (canManualConfirm)
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: onManualConfirm,
icon: const Icon(Icons.check_circle_outline, size: 18),
label: const Text('Manuell als geholt bestätigen'),
),
),
),
],
),
);
}
}
class _DoneBar extends StatelessWidget {
const _DoneBar({required this.onConfirm});
final VoidCallback onConfirm;
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
color: Colors.green.withValues(alpha: 0.12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(Icons.check_circle, color: Colors.green.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
'Alle Filial-Artikel geladen',
style: TextStyle(
fontWeight: FontWeight.w700,
color: Colors.green.shade800,
),
),
),
],
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onConfirm,
icon: const Icon(Icons.arrow_back),
label: const Text('Fertig — zurück zur Übersicht'),
),
),
],
),
),
);
}
}

View File

@ -1,95 +0,0 @@
import 'package:flutter/foundation.dart';
/// Repository für prozess-übergreifende Aktionen entlang des
/// Phasen-Workflows (Sortieren → Beladen → Ausliefern).
///
/// Aktuell nur ein Stub: Sobald der entsprechende Backend-Endpunkt
/// von DOCUcontrol/VARIOcontrol bereitsteht, wird hier die echte
/// HTTP-Implementierung eingehängt — die Aufrufer (TourBloc) bleiben
/// dann unverändert.
class ProcessRepository {
ProcessRepository();
/// Persistiert die vom Fahrer bestätigte Lieferreihenfolge für ein
/// Fahrzeug am Backend. Wird ausschließlich beim Bestätigen-Button
/// in der Sortier-Page aufgerufen, nicht bei jedem Drag&Drop —
/// das lokale Reorder läuft weiterhin nur über ReorderService.
Future<void> persistDeliveryOrder({
required String carId,
required List<String> orderedDeliveryIds,
}) async {
// STUB: Echte HTTP-Implementierung folgt, sobald der Endpoint steht.
await Future.delayed(const Duration(milliseconds: 200));
debugPrint(
"ProcessRepository.persistDeliveryOrder (stub) — carId=$carId, "
"order=$orderedDeliveryIds",
);
}
/// Hebt die Zuordnung einer Lieferung zu einem Fahrzeug auf. Wird im
/// Auswahl-Schritt verwendet, wenn der Fahrer eine bereits seinem Auto
/// zugeordnete Lieferung wieder "freigibt", damit ein Kollege sie
/// übernehmen kann.
///
/// TODO(backend): Echte HTTP-Implementierung sobald der DOCUcontrol-
/// bzw. VARIOcontrol-Endpoint zum Entfernen der Fahrzeug-Zuordnung
/// steht. Aktuell nur Stub mit Log, damit der UI-Flow getestet werden
/// kann; die Tour wird nach diesem Call NICHT automatisch refresht
/// (das übernimmt später der Stream nach dem echten Endpoint).
Future<void> unassignDeliveryFromCar({
required String deliveryId,
}) async {
// STUB: Echte HTTP-Implementierung folgt, sobald der Endpoint steht.
await Future.delayed(const Duration(milliseconds: 200));
debugPrint(
"ProcessRepository.unassignDeliveryFromCar (stub) — "
"deliveryId=$deliveryId",
);
}
/// Meldet einen vom Fahrer dokumentierten Lieferungs-Abbruch (komplette
/// Lieferung) an das Backend mit Begründung. Wird zusätzlich zum
/// bestehenden `cancelDelivery`-Endpoint im [TourRepository] gefeuert,
/// damit auf Backend-Seite die Stornogründe getrennt geführt werden
/// können (Reporting / Logistik-Audit).
///
/// TODO(backend): Echte HTTP-Implementierung sobald der DOCUcontrol-
/// Endpoint zum Berichten eines Abbruch-Grunds bereitsteht. Bis dahin
/// nur Log, damit der UI-Flow geübt werden kann.
Future<void> reportDeliveryCancelled({
required String deliveryId,
required String reason,
}) async {
// STUB: Echte HTTP-Implementierung folgt, sobald der Endpoint steht.
await Future.delayed(const Duration(milliseconds: 200));
debugPrint(
"ProcessRepository.reportDeliveryCancelled (stub) — "
"deliveryId=$deliveryId, reason=$reason",
);
}
/// Meldet einen einzelnen Artikel oder eine Komponente als "heute nicht
/// auszuliefern" (Teilabbruch). Der Eintrag bleibt in der Lieferung
/// bestehen, wird aber im UI ausgegraut und vom Beladen-Fortschritt
/// ausgeschlossen. [componentId] wird genau dann gesetzt, wenn nur eine
/// einzelne Stücklisten-Position betroffen ist.
///
/// TODO(backend): Echte HTTP-Implementierung sobald der DOCUcontrol-/
/// VARIOcontrol-Endpoint für "Position zurückhalten" bereitsteht. Bis
/// dahin wird der Hold-State ausschließlich lokal in der jeweiligen
/// Beladen-Page gehalten (siehe `LoadingCustomerPage._heldKeys`).
Future<void> reportItemHeld({
required String deliveryId,
required String articleId,
String? componentId,
required String reason,
}) async {
// STUB: Echte HTTP-Implementierung folgt, sobald der Endpoint steht.
await Future.delayed(const Duration(milliseconds: 200));
debugPrint(
"ProcessRepository.reportItemHeld (stub) — "
"deliveryId=$deliveryId, articleId=$articleId, "
"componentId=$componentId, reason=$reason",
);
}
}

View File

@ -1,441 +0,0 @@
import 'dart:typed_data';
import 'package:hl_lieferservice/dto/set_article_amount_response.dart';
import 'package:hl_lieferservice/feature/delivery/service/tour_service.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:rxdart/rxdart.dart';
import '../../../../dto/discount_add_response.dart';
import '../../../../dto/discount_remove_response.dart';
import '../../../../dto/discount_update_response.dart';
import '../../../../model/article.dart';
import '../detail/repository/note_repository.dart';
import '../detail/service/notes_service.dart';
enum ScanResult { scanned, alreadyScanned, notFound }
class TourNotFoundException implements Exception {}
class TourRepository {
TourService service;
final _tourStream = BehaviorSubject<Tour?>();
final _paymentOptionsStream = BehaviorSubject<List<Payment>>.seeded([]);
Stream<Tour?> get tour => _tourStream.stream;
Stream<List<Payment>> get paymentOptions => _paymentOptionsStream.stream;
TourRepository({required this.service});
Future<void> loadTourOfToday(String userId) async {
_tourStream.add(await service.getTourOfToday(userId));
}
Future<void> loadPaymentOptions() async {
_paymentOptionsStream.add(
(await service.getPaymentMethods())
.map((option) => Payment.fromDTO(option))
.toList(),
);
}
Future<void> assignCar(String deliveryId, String carId) async {
await service.assignCar(deliveryId, carId);
final tour = _tourStream.value!;
final index = tour.deliveries.indexWhere(
(delivery) => delivery.id == deliveryId,
);
tour.deliveries[index].carId = carId;
_tourStream.add(tour);
}
Future<ScanResult> scanArticle(
String deliveryId,
String carId,
String articleNumber,
) async {
if (!_tourStream.hasValue) {
throw TourNotFoundException();
}
final tour = _tourStream.value!;
if (tour.deliveries.any(
(delivery) => delivery.articles.any(
(article) => article.articleNumber == articleNumber,
),
)) {
var delivery = tour.deliveries.firstWhere(
(delivery) => delivery.id == deliveryId,
);
var article = delivery.articles.firstWhere(
(article) => article.articleNumber == articleNumber,
);
await service.scanArticle(article.internalId.toString());
if (article.scannedAmount < article.amount) {
article.scannedAmount += 1;
delivery.carId = carId;
await service.assignCar(deliveryId, carId);
_tourStream.add(tour);
return ScanResult.scanned;
} else {
return ScanResult.alreadyScanned;
}
} else {
return ScanResult.notFound;
}
}
/// Scan a single BOM component locally. The server-side `scanArticle` call
/// for the parent article is deferred until **every** component of the
/// parent is fully scanned — only then does the parent count as loaded.
Future<ScanResult> scanComponent(
String deliveryId,
String carId,
String componentArticleNumber,
) async {
if (!_tourStream.hasValue) {
throw TourNotFoundException();
}
final tour = _tourStream.value!;
final delivery = tour.deliveries.firstWhere(
(d) => d.id == deliveryId,
);
// Locate the parent article and the matching component.
final parentArticle = delivery.findParentOfComponent(
componentArticleNumber,
);
if (parentArticle == null) return ScanResult.notFound;
final component = parentArticle.findComponent(componentArticleNumber)!;
if (component.isFullyScanned) return ScanResult.alreadyScanned;
// ── Local-only increment ──
component.scannedAmount += 1;
// ── When every component is done, sync the parent with the server ──
if (parentArticle.isFullyScanned) {
await service.scanArticle(parentArticle.internalId.toString());
parentArticle.scannedAmount += 1;
delivery.carId = carId;
await service.assignCar(deliveryId, carId);
}
_tourStream.add(tour);
return ScanResult.scanned;
}
Future<void> unscan(
String deliveryId,
String articleId,
int newAmount,
String reason,
) async {
if (!_tourStream.hasValue) {
throw TourNotFoundException();
}
Tour tour = _tourStream.value!;
String? noteId = await service.unscanArticle(articleId, newAmount, reason);
Article article = tour.deliveries
.firstWhere((delivery) => delivery.id == deliveryId)
.articles
.firstWhere((article) => article.internalId == int.parse(articleId));
article.removeNoteId = noteId;
article.scannedRemovedAmount += newAmount;
article.scannedAmount -= newAmount;
_tourStream.add(tour);
}
Future<void> resetScan(String articleId, String deliveryId) async {
if (!_tourStream.hasValue) {
throw TourNotFoundException();
}
Tour tour = _tourStream.value!;
await service.resetScannedArticleAmount(articleId);
Article article = tour.deliveries
.firstWhere((delivery) => delivery.id == deliveryId)
.articles
.firstWhere((article) => article.internalId == int.parse(articleId));
article.removeNoteId = null;
article.scannedRemovedAmount = 0;
article.scannedAmount = article.amount;
_tourStream.add(tour);
}
Future<void> uploadDriverSignature(
String deliveryId,
Uint8List signature,
) async {
NoteRepository noteRepository = NoteRepository(service: NoteService());
await noteRepository.addNamedImage(
deliveryId,
signature,
"delivery_${deliveryId}_signature_driver.jpg",
);
}
Future<void> uploadCustomerSignature(
String deliveryId,
Uint8List signature,
) async {
NoteRepository noteRepository = NoteRepository(service: NoteService());
await noteRepository.addNamedImage(
deliveryId,
signature,
"delivery_${deliveryId}_signature_customer.jpg",
);
}
Future<void> addDiscount(String deliveryId, String reason, int value) async {
if (!_tourStream.hasValue) {
throw TourNotFoundException();
}
Tour tour = _tourStream.value!;
DiscountAddResponseDTO response = await service.addDiscount(
deliveryId,
value,
reason,
);
Delivery delivery = tour.deliveries.firstWhere(
(delivery) => delivery.id == deliveryId,
);
Article discountArticle = Article.fromDTO(response.values.article);
delivery.totalNetValue = response.values.receipt.net;
delivery.totalGrossValue = response.values.receipt.gross;
delivery.discount = Discount(
article: Article.fromDTO(response.values.article),
note: response.values.note.noteDescription,
noteId: response.values.note.rowId,
);
delivery.articles = [
...delivery.articles,
discountArticle,
];
_tourStream.add(tour);
}
Future<void> removeDiscount(String deliveryId) async {
if (!_tourStream.hasValue) {
throw TourNotFoundException();
}
Tour tour = _tourStream.value!;
DiscountRemoveResponseDTO response = await service.removeDiscount(
deliveryId,
);
Delivery delivery = tour.deliveries.firstWhere(
(delivery) => delivery.id == deliveryId,
);
delivery.articles =
delivery.articles
.where(
(article) =>
article.internalId != delivery.discount?.article.internalId,
)
.toList();
delivery.discount = null;
delivery.totalGrossValue = response.receipt.gross;
delivery.totalNetValue = response.receipt.net;
_tourStream.add(tour);
}
Future<void> updateDiscount(
String deliveryId,
String? reason,
int? value,
) async {
if (!_tourStream.hasValue) {
throw TourNotFoundException();
}
Tour tour = _tourStream.value!;
DiscountUpdateResponseDTO response = await service.updateDiscount(
deliveryId,
reason,
value,
);
Delivery delivery = tour.deliveries.firstWhere(
(delivery) => delivery.id == deliveryId,
);
if (response.values?.receipt != null) {
delivery.totalNetValue = response.values!.receipt.net;
delivery.totalGrossValue = response.values!.receipt.gross;
}
String discountArticleNumber = delivery.discount!.article.articleNumber;
delivery.discount = Discount(
article:
response.values?.article != null
? Article.fromDTO(response.values!.article)
: delivery.discount!.article,
note:
response.values?.note != null
? response.values!.note.noteDescription
: delivery.discount!.note,
noteId:
response.values?.note != null
? response.values!.note.rowId
: delivery.discount!.noteId,
);
delivery.articles = [
...delivery.articles.where(
(article) => article.articleNumber != discountArticleNumber,
),
delivery.discount!.article,
];
_tourStream.add(tour);
}
Future<void> reactivateDelivery(String deliveryId) async {
await _changeState(deliveryId, DeliveryState.ongoing);
}
Future<void> holdDelivery(String deliveryId) async {
await _changeState(deliveryId, DeliveryState.onhold);
}
Future<void> cancelDelivery(String deliveryId) async {
await _changeState(deliveryId, DeliveryState.canceled);
}
Future<void> finishDelivery(String deliveryId) async {
await _changeState(deliveryId, DeliveryState.finished);
Delivery delivery = _tourStream.value!.deliveries.firstWhere(
(delivery) => delivery.id == deliveryId,
);
await _updateDelivery(delivery);
await service.finishDelivery(deliveryId);
}
Future<void> setArticleAmount(
String deliveryId,
String articleId,
int amount,
String? reason,
) async {
if (!_tourStream.hasValue) {
throw TourNotFoundException();
}
try {
SetArticleAmountResponseDTO dto = await service.setArticleAmount(
deliveryId,
articleId,
amount,
reason,
);
Delivery delivery = _tourStream.value!.deliveries.firstWhere(
(delivery) => delivery.id == deliveryId,
);
Article article = delivery.articles.firstWhere(
(article) => article.internalId == int.parse(articleId),
);
article.amount = amount;
article.removeNoteId = dto.noteId;
_tourStream.add(_tourStream.value);
} catch (_) {
rethrow;
}
}
Future<void> _changeState(String deliveryId, DeliveryState state) async {
if (!_tourStream.hasValue) {
throw TourNotFoundException();
}
Tour tour = _tourStream.value!;
Delivery delivery = tour.deliveries.firstWhere(
(delivery) => delivery.id == deliveryId,
);
delivery.state = state;
await _updateDelivery(delivery);
_tourStream.add(tour);
}
Future<void> _updateDelivery(Delivery newDelivery) async {
if (!_tourStream.hasValue) {
throw TourNotFoundException();
}
await service.updateDelivery(newDelivery);
}
Future<void> updatePayment(String deliveryId, Payment payment) async {
if (!_tourStream.hasValue) {
throw TourNotFoundException();
}
Tour tour = _tourStream.value!;
Delivery delivery = tour.deliveries.firstWhere(
(delivery) => delivery.id == deliveryId,
);
delivery.payment = payment;
await _updateDelivery(delivery);
_tourStream.add(tour);
}
Future<void> updateOption(
String deliveryId,
String key,
dynamic value,
) async {
if (!_tourStream.hasValue) {
throw TourNotFoundException();
}
Tour tour = _tourStream.value!;
Delivery delivery = tour.deliveries.firstWhere(
(delivery) => delivery.id == deliveryId,
);
delivery.options =
delivery.options.map((option) {
if (option.key == key) {
if (option.numerical) {
return option.copyWith(value: value);
} else {
return option.copyWith(value: value == true ? "1" : "0");
}
}
return option;
}).toList();
await _updateDelivery(delivery);
_tourStream.add(tour);
}
}

View File

@ -1,448 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:hl_lieferservice/dto/delivery_response.dart';
import 'package:hl_lieferservice/dto/delivery_update.dart';
import 'package:hl_lieferservice/dto/delivery_update_response.dart';
import 'package:hl_lieferservice/dto/payment.dart';
import 'package:hl_lieferservice/dto/payments.dart';
import 'package:hl_lieferservice/dto/set_article_amount_request.dart';
import 'package:hl_lieferservice/dto/set_article_amount_response.dart';
import 'package:hl_lieferservice/model/car.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:hl_lieferservice/util.dart';
import 'package:http/http.dart';
import '../../../dto/basic_response.dart';
import '../../../dto/discount_add_response.dart';
import '../../../dto/discount_remove_response.dart';
import '../../../dto/discount_update_response.dart';
import '../../../dto/scan_response.dart';
import '../../authentication/exceptions.dart';
class TourService {
TourService();
Future<void> updateDelivery(Delivery delivery) async {
try {
var headers = {"Content-Type": "application/json"};
headers.addAll(getSessionOrThrow());
debugPrint(getSessionOrThrow().toString());
debugPrint(delivery.state.toString());
debugPrint(jsonEncode(DeliveryUpdateDTO.fromEntity(delivery).toJson()));
var response = await post(
urlBuilder("_web_updateDelivery"),
headers: headers,
body: jsonEncode(DeliveryUpdateDTO.fromEntity(delivery).toJson()),
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
debugPrint("BODY: ${response.body}");
Map<String, dynamic> responseJson = jsonDecode(response.body);
DeliveryUpdateResponseDTO responseDto =
DeliveryUpdateResponseDTO.fromJson(responseJson);
if (responseDto.code == "200") {
return;
}
debugPrint("ERROR UPDATING:");
debugPrint(responseDto.message);
} catch (e, st) {
debugPrint("ERROR WHILE UPDATING DELIVERY");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
Future<void> assignCar(String deliveryId, String carId) async {
try {
var headers = {"Content-Type": "application/json"};
headers.addAll(getSessionOrThrow());
var response = await post(
urlBuilder("_web_updateDelivery"),
headers: headers,
body: jsonEncode({"delivery_id": deliveryId, "car_id": carId}),
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
DeliveryUpdateResponseDTO responseDto =
DeliveryUpdateResponseDTO.fromJson(responseJson);
if (responseDto.code == "200") {
return;
}
debugPrint("ERROR UPDATING:");
debugPrint(responseDto.message);
} catch (e, st) {
debugPrint("ERROR WHILE UPDATING DELIVERY");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
/// List all available deliveries for today.
Future<Tour> getTourOfToday(String userId) async {
try {
var response = await post(
urlBuilder("_web_getDeliveries"),
headers: getSessionOrThrow(),
body: {"driver_id": userId, "date": getTodayDate()},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
DeliveryResponseDTO responseDto = DeliveryResponseDTO.fromJson(
jsonDecode(response.body),
);
return Tour(
discountArticleNumber: responseDto.discountArticleNumber,
date: DateTime.now(),
deliveries: responseDto.deliveries.map(Delivery.fromDTO).toList(),
paymentMethods: [],
driver: Driver(
cars:
responseDto.driver.cars
.map(
// Legacy: alte ERPframe-CarDto hat int-IDs, neue
// Domain-Entity erwartet UUID-Strings. Wir
// stringifizieren die int-ID und füllen
// accountId/active mit Stub-Werten — der ganze
// Service wird in Phase D entfernt.
(carDto) => Car(
id: carDto.id,
accountId: 0,
plate: carDto.plate,
active: true,
),
)
.toList(),
teamNumber: int.parse(responseDto.driver.id),
name: responseDto.driver.name,
salutation: responseDto.driver.salutation,
),
);
} catch (e, stacktrace) {
debugPrint(e.toString());
debugPrint(stacktrace.toString());
debugPrint("RANDOM EXCEPTION!");
rethrow;
}
}
Future<List<PaymentMethodDTO>> getPaymentMethods() async {
try {
var response = await post(
urlBuilder("_web_getPaymentMethods"),
headers: getSessionOrThrow(),
body: {},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
PaymentMethodListDTO responseDto = PaymentMethodListDTO.fromJson(
responseJson,
);
return responseDto.paymentMethods;
} catch (e, st) {
debugPrint("ERROR while retrieving allowed payment methods");
debugPrint(e.toString());
debugPrint(st.toString());
rethrow;
}
}
Future<String?> unscanArticle(
String internalId,
int amount,
String reason,
) async {
try {
var response = await post(
urlBuilder("_web_unscanArticle"),
headers: getSessionOrThrow(),
body: {
"article_id": internalId,
"amount": amount.toString(),
"reason": reason,
},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
ScanResponseDTO responseDto = ScanResponseDTO.fromJson(responseJson);
if (responseDto.succeeded == true) {
return responseDto.noteId;
} else {
throw responseDto.message;
}
} catch (e, st) {
debugPrint("ERROR WHILE REVERTING THE SCAN OF ARTICLE $internalId");
debugPrint(e.toString());
debugPrint(st.toString());
rethrow;
}
}
Future<void> resetScannedArticleAmount(String receiptRowId) async {
try {
var response = await post(
urlBuilder("_web_unscanArticleReset"),
headers: getSessionOrThrow(),
body: {"receipt_row_id": receiptRowId},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
BasicResponseDTO responseDto = BasicResponseDTO.fromJson(responseJson);
if (responseDto.succeeded == true) {
return;
} else {
throw responseDto.message;
}
} catch (e, st) {
debugPrint("ERROR WHILE REVERTING THE UNSCAN OF ARTICLE $receiptRowId");
debugPrint(e.toString());
debugPrint(st.toString());
rethrow;
}
}
Future<DiscountAddResponseDTO> addDiscount(
String deliveryId,
int discount,
String note,
) async {
try {
var response = await post(
urlBuilder("_web_addDiscount"),
headers: getSessionOrThrow(),
body: {
"delivery_id": deliveryId,
"discount": discount.toString(),
"note": note,
},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
// let it throw, if the values are invalid
return DiscountAddResponseDTO.fromJson(responseJson);
} catch (e, st) {
debugPrint("ERROR while adding discount");
debugPrint(e.toString());
debugPrint(st.toString());
rethrow;
}
}
Future<BasicResponseDTO> finishDelivery(String deliveryId) async {
try {
// ISO-8601 mit T-Separator: sprachunabhaengig fuer SQL-Server datetime.
// ('yyyy-MM-dd HH:mm:ss' OHNE T wird unter DATEFORMAT=DMY (DE) als YDM
// geparst und schlaegt fuer Tag > 12 fehl.)
// ERPframe arbeitet mit lokaler Zeit -> bewusst keine UTC-Konvertierung.
final String deliveredAt = DateFormat(
"yyyy-MM-dd'T'HH:mm:ss",
).format(DateTime.now());
var headers = {"Content-Type": "application/json"};
headers.addAll(getSessionOrThrow());
var response = await post(
urlBuilder("_web_finishDelivery"),
headers: headers,
body: jsonEncode({
"delivery_id": deliveryId,
"delivered_at": deliveredAt,
}),
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
debugPrint("BODY: ${response.body}");
Map<String, dynamic> responseJson = jsonDecode(response.body);
// let it throw, if the values are invalid
return BasicResponseDTO.fromJson(responseJson);
} catch (e, st) {
debugPrint("ERROR while adding discount");
debugPrint(e.toString());
debugPrint(st.toString());
rethrow;
}
}
Future<SetArticleAmountResponseDTO> setArticleAmount(
String deliveryId,
String articleId,
int amount,
String? reason,
) async {
try {
var response = await post(
urlBuilder("_web_setArticleAmount"),
headers: {...getSessionOrThrow(), "Content-Type": "application/json"},
body: jsonEncode(
SetArticleAmountRequestDTO(
articleId: articleId,
deliveryId: deliveryId,
amount: amount,
reason: reason,
),
),
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
debugPrint("BODY: ${response.body}");
Map<String, dynamic> responseJson = jsonDecode(response.body);
// let it throw, if the values are invalid
SetArticleAmountResponseDTO responseDto =
SetArticleAmountResponseDTO.fromJson(responseJson);
if (!responseDto.succeeded) {
throw responseDto.message;
} else {
return responseDto;
}
} catch (e, st) {
debugPrint(e.toString());
debugPrint(st.toString());
rethrow;
}
}
Future<DiscountRemoveResponseDTO> removeDiscount(String deliveryId) async {
try {
var response = await post(
urlBuilder("_web_removeDiscount"),
headers: getSessionOrThrow(),
body: {"delivery_id": deliveryId},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
// let it throw, if the values are invalid
return DiscountRemoveResponseDTO.fromJson(responseJson);
} catch (e, st) {
debugPrint("ERROR while removing discount");
debugPrint(e.toString());
debugPrint(st.toString());
rethrow;
}
}
Future<DiscountUpdateResponseDTO> updateDiscount(
String deliveryId,
String? note,
int? discount,
) async {
try {
var response = await post(
urlBuilder("_web_updateDiscount"),
headers: getSessionOrThrow(),
body: {"delivery_id": deliveryId, "discount": discount, "note": note},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
// let it throw, if the values are invalid
return DiscountUpdateResponseDTO.fromJson(responseJson);
} catch (e, st) {
debugPrint("ERROR while retrieving allowed payment methods");
debugPrint(e.toString());
debugPrint(st.toString());
rethrow;
}
}
Future<void> scanArticle(String internalId) async {
try {
var response = await post(
urlBuilder("_web_scanArticle"),
headers: getSessionOrThrow(),
body: {"internal_id": internalId},
);
debugPrint(jsonEncode({"internal_id": internalId}));
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
debugPrint(responseJson.toString());
ScanResponseDTO responseDto = ScanResponseDTO.fromJson(responseJson);
if (responseDto.succeeded == true) {
return;
} else {
debugPrint("ERROR: ${responseDto.message}");
throw responseDto.message;
}
} catch (e) {
rethrow;
}
}
}

View File

@ -1,22 +0,0 @@
List<String> reorderList(List<String> old, int oldIndex, int newIndex) {
List<String> tmp = [...old];
int newIndexCalc = newIndex - 1;
if (newIndex < oldIndex) {
newIndexCalc = newIndex;
}
if (newIndex == old.length) {
newIndexCalc = old.length - 1;
}
if (newIndex == 0) {
newIndexCalc = 0;
}
String oldItem = tmp.removeAt(oldIndex);
tmp.insert(newIndexCalc, oldItem);
return tmp;
}