Final commit.
This commit is contained in:
@ -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
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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});
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
46
lib/feature/delivery/detail/bloc/workflow_bloc.dart
Normal file
46
lib/feature/delivery/detail/bloc/workflow_bloc.dart
Normal 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),
|
||||
));
|
||||
}
|
||||
}
|
||||
43
lib/feature/delivery/detail/bloc/workflow_event.dart
Normal file
43
lib/feature/delivery/detail/bloc/workflow_event.dart
Normal 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;
|
||||
}
|
||||
87
lib/feature/delivery/detail/bloc/workflow_state.dart
Normal file
87
lib/feature/delivery/detail/bloc/workflow_state.dart
Normal 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();
|
||||
@ -1 +0,0 @@
|
||||
class NoteImageAddException implements Exception {}
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_article_management.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_delivery_options.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_info.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_note.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_summary.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
abstract class IStepFactory {
|
||||
Widget? make(int step, Delivery delivery);
|
||||
}
|
||||
|
||||
class StepFactory extends IStepFactory {
|
||||
@override
|
||||
Widget? make(int step, Delivery delivery) {
|
||||
switch(step) {
|
||||
case 0:
|
||||
return DeliveryStepInfo(delivery: delivery);
|
||||
case 1:
|
||||
return DeliveryStepNote(delivery: delivery);
|
||||
case 2:
|
||||
return DeliveryStepArticleManagement(delivery: delivery);
|
||||
case 3:
|
||||
return DeliveryStepOptions(delivery: delivery);
|
||||
case 4:
|
||||
return DeliveryStepSummary(delivery: delivery);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/article/article_list.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_discount.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
class DeliveryStepArticleManagement extends StatefulWidget {
|
||||
final Delivery delivery;
|
||||
|
||||
const DeliveryStepArticleManagement({required this.delivery, super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DeliveryStepInfo();
|
||||
}
|
||||
|
||||
class _DeliveryStepInfo extends State<DeliveryStepArticleManagement> {
|
||||
Widget _articleOverview() {
|
||||
TourLoaded tour = context.read<TourBloc>().state as TourLoaded;
|
||||
|
||||
return ArticleList(
|
||||
articles:
|
||||
widget.delivery.articles
|
||||
.where(
|
||||
(article) =>
|
||||
article.articleNumber != tour.tour.discountArticleNumber,
|
||||
)
|
||||
.toList(),
|
||||
deliveryId: widget.delivery.id,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _discountView() {
|
||||
return DeliveryDiscount(
|
||||
disabled: false,
|
||||
discount: widget.delivery.discount,
|
||||
deliveryId: widget.delivery.id,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Text(
|
||||
"Artikel",
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
_articleOverview(),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
child: Text(
|
||||
"Gutschriften",
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
|
||||
_discountView(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,395 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/scan_progress.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/discount_editor.dart';
|
||||
import 'package:hl_lieferservice/feature/loading/widget/reason_catalog.dart';
|
||||
import 'package:hl_lieferservice/feature/loading/widget/reason_picker_sheet.dart';
|
||||
|
||||
/// Step 3 — Artikel & Gutschriften.
|
||||
///
|
||||
/// * **Mengen-Gutschrift** (Belegzeile ganz ODER teilweise entfernen) ist
|
||||
/// voll funktional über `RemoveItem`/`UnremoveItem` (mit `quantity`) →
|
||||
/// Backend Audit-Action `remove`/`unremove`. Die `creditedQuantity` des
|
||||
/// Items ist die Wahrheit (vom Server), kein lokaler Draft mehr.
|
||||
/// Regeln (serverseitig erzwungen): scannbare Position muss `done` sein,
|
||||
/// Lieferung muss `active` sein. Die UI spiegelt das als Gate + Hinweis.
|
||||
/// * **Betrags-Gutschrift** (≤ 150 € in 10er-Schritten + Grund) ist
|
||||
/// backend-gestützt über `SetDeliveryCredit`/`RemoveDeliveryCredit` am
|
||||
/// `TourBloc` → `POST /deliveries/{id}/credit`. Wahrheit ist der Server
|
||||
/// (`TourDetails.creditOf`), kein lokaler Draft mehr.
|
||||
class StepArticles extends StatelessWidget {
|
||||
const StepArticles({
|
||||
super.key,
|
||||
required this.delivery,
|
||||
required this.details,
|
||||
});
|
||||
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Innerhalb einer Belegzeile: Oberartikel vor seinen Komponenten, damit
|
||||
// Komponenten direkt darunter eingerückt erscheinen.
|
||||
final items = List<DeliveryItem>.of(delivery.items)
|
||||
..sort((a, b) {
|
||||
final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr);
|
||||
if (byLine != 0) return byLine;
|
||||
final byParent =
|
||||
(a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0);
|
||||
if (byParent != 0) return byParent;
|
||||
return (a.komponentenArtikelNr ?? '')
|
||||
.compareTo(b.komponentenArtikelNr ?? '');
|
||||
});
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
_SectionHeader(text: 'Artikel'),
|
||||
const SizedBox(height: 8),
|
||||
if (delivery.state != DeliveryState.active) ...[
|
||||
const _LockedHint(
|
||||
text: 'Nur bei aktiver Lieferung änderbar.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (items.isEmpty)
|
||||
const _EmptyHint(text: 'Keine Artikel hinterlegt.')
|
||||
else
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < items.length; i++) ...[
|
||||
_ArticleManagementRow(
|
||||
item: items[i],
|
||||
details: details,
|
||||
deliveryId: delivery.id,
|
||||
deliveryActive: delivery.state == DeliveryState.active,
|
||||
),
|
||||
if (i < items.length - 1)
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SectionHeader(text: 'Gutschriften'),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: DiscountEditor(
|
||||
deliveryId: delivery.id,
|
||||
active: delivery.state == DeliveryState.active,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.text});
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyHint extends StatelessWidget {
|
||||
const _EmptyHint({required this.text});
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Zeile in der Artikel-Verwaltung. Quelle der Wahrheit ist
|
||||
/// `item.scanProgress.creditedQuantity` (vom Backend) — kein lokaler Draft.
|
||||
/// Zeigt:
|
||||
/// - verbleibende Liefermenge (Soll − Gutschrift)
|
||||
/// - Gutschrift-Button → Mengen-Dialog (1…Restmenge + Grund), gesperrt für
|
||||
/// scannbare, noch nicht gescannte Positionen oder inaktive Lieferung
|
||||
/// - Wiederherstellen-Button, sobald etwas gutgeschrieben ist
|
||||
class _ArticleManagementRow extends StatelessWidget {
|
||||
const _ArticleManagementRow({
|
||||
required this.item,
|
||||
required this.details,
|
||||
required this.deliveryId,
|
||||
required this.deliveryActive,
|
||||
});
|
||||
|
||||
final DeliveryItem item;
|
||||
final TourDetails details;
|
||||
final String deliveryId;
|
||||
final bool deliveryActive;
|
||||
|
||||
Future<void> _openCreditDialog(
|
||||
BuildContext context, {
|
||||
required int remaining,
|
||||
}) async {
|
||||
final tourBloc = context.read<TourBloc>();
|
||||
final actorCarId = _actorCarId(context);
|
||||
// Identische Auswahl wie im Beladen-/Scan-Screen: Grund-Picker
|
||||
// (Presets + „Anderer Grund") + Mengen-Stepper (Teilmengen-Gutschrift).
|
||||
final result = await showReasonPickerSheet(
|
||||
context: context,
|
||||
title: 'Grund für das Entfernen',
|
||||
presets: ReasonCatalog.itemRemove,
|
||||
confirmLabel: 'Entfernen',
|
||||
maxQuantity: remaining,
|
||||
);
|
||||
if (result == null) return;
|
||||
|
||||
// Eine Aktion für ganz UND teilweise: das Backend kippt die Zeile auf
|
||||
// `removed`, sobald die volle Menge gutgeschrieben ist.
|
||||
tourBloc.add(RemoveItem(
|
||||
deliveryItemId: item.id,
|
||||
reason: result.reason,
|
||||
actorCarId: actorCarId,
|
||||
quantity: result.quantity,
|
||||
// Gutschrift-Grund zusätzlich als Lieferungs-Notiz festhalten.
|
||||
saveReasonAsNote: true,
|
||||
));
|
||||
}
|
||||
|
||||
void _restoreAll(BuildContext context) {
|
||||
// quantity: null → gesamte Gutschrift zurücknehmen.
|
||||
context.read<TourBloc>().add(UnremoveItem(
|
||||
deliveryItemId: item.id,
|
||||
actorCarId: _actorCarId(context),
|
||||
));
|
||||
}
|
||||
|
||||
/// Holt die aktuelle Auto-ID aus dem CarSelectBloc — gleiche Konvention
|
||||
/// wie der Loading-Flow: das aktiv gewählte Fahrzeug ist der „Akteur"
|
||||
/// des Audit-Events. Falls (defensiv) noch keine Auswahl getroffen ist,
|
||||
/// fallback auf einen Null-UUID-String, damit der Backend-Call nicht
|
||||
/// validation-failt.
|
||||
String _actorCarId(BuildContext context) {
|
||||
final state = context.read<CarSelectBloc>().state;
|
||||
if (state is CarSelectComplete) return state.selectedCar.id;
|
||||
return '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final article = details.articleOf(item.articleId);
|
||||
final warehouse = details.warehouseOf(item.warehouseId);
|
||||
|
||||
final required = item.requiredQuantity;
|
||||
final credited = item.scanProgress.creditedQuantity;
|
||||
final remaining = required - credited;
|
||||
final fullyRemoved = item.isRemoved; // Status == removed (voll gutgeschr.)
|
||||
final partiallyCredited = credited > 0 && !fullyRemoved;
|
||||
|
||||
// Gate: scannbare Position muss `done` sein, sonst keine Gutschrift.
|
||||
final scannable = article?.scannable ?? false;
|
||||
final isDone = item.scanProgress.status == ScanStatus.done;
|
||||
final blockedByScan = scannable && !isDone && !fullyRemoved;
|
||||
final canCredit = deliveryActive && !blockedByScan && remaining > 0;
|
||||
|
||||
final Color avatarColor;
|
||||
final String avatarText;
|
||||
if (fullyRemoved) {
|
||||
avatarColor = Colors.red.shade400;
|
||||
avatarText = '0×';
|
||||
} else if (partiallyCredited) {
|
||||
avatarColor = Colors.amber.shade700;
|
||||
avatarText = '$remaining×';
|
||||
} else {
|
||||
avatarColor = theme.colorScheme.primary;
|
||||
avatarText = '$required×';
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
// Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber).
|
||||
contentPadding:
|
||||
EdgeInsets.only(left: item.isComponent ? 40 : 16, right: 16),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: avatarColor,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
child: Text(
|
||||
avatarText,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
'${item.isComponent ? '↳ ' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: fullyRemoved ? TextDecoration.lineThrough : null,
|
||||
color: fullyRemoved ? theme.colorScheme.onSurfaceVariant : null,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
[
|
||||
article?.articleNumber ?? item.articleId,
|
||||
if (warehouse != null) warehouse.name,
|
||||
if (article?.scannable == false) 'Dienstleistung',
|
||||
].join(' · '),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (fullyRemoved)
|
||||
_StatusLine(
|
||||
text: 'Komplett gutgeschrieben'
|
||||
'${item.scanProgress.heldReason != null ? ' – ${item.scanProgress.heldReason}' : ''}',
|
||||
color: Colors.red.shade400,
|
||||
)
|
||||
else if (partiallyCredited)
|
||||
_StatusLine(
|
||||
text: '$credited von $required gutgeschrieben',
|
||||
color: Colors.amber.shade800,
|
||||
),
|
||||
if (blockedByScan)
|
||||
_StatusLine(
|
||||
text: 'Erst scannen/verladen — dann Gutschrift möglich',
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (credited > 0)
|
||||
IconButton(
|
||||
// Wiederherstellen nur bei aktiver Lieferung — bei
|
||||
// abgeschlossener/abgebrochener Lieferung gesperrt (greift auch
|
||||
// backend-seitig, hier zusätzlich in der UI).
|
||||
tooltip: deliveryActive
|
||||
? 'Gutschrift zurücknehmen'
|
||||
: 'Nur bei aktiver Lieferung',
|
||||
icon: Icon(
|
||||
Icons.restore,
|
||||
color: deliveryActive
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onPressed: deliveryActive ? () => _restoreAll(context) : null,
|
||||
),
|
||||
if (!fullyRemoved)
|
||||
IconButton.outlined(
|
||||
tooltip: blockedByScan
|
||||
? 'Erst scannen/verladen'
|
||||
: (!deliveryActive
|
||||
? 'Nur bei aktiver Lieferung'
|
||||
: 'Gutschrift / entfernen'),
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
canCredit
|
||||
? Colors.redAccent
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
onPressed: canCredit
|
||||
? () => _openCreditDialog(context, remaining: remaining)
|
||||
: null,
|
||||
icon: Icon(
|
||||
Icons.delete,
|
||||
color: canCredit
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kleine farbige Statuszeile unter dem Artikelnamen.
|
||||
class _StatusLine extends StatelessWidget {
|
||||
const _StatusLine({required this.text, required this.color});
|
||||
final String text;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock-Hinweis (analog DiscountEditor): zeigt mit Schloss-Symbol an, dass die
|
||||
/// Artikel-Aktionen (Entfernen / Wiederherstellen) nur bei aktiver Lieferung
|
||||
/// möglich sind.
|
||||
class _LockedHint extends StatelessWidget {
|
||||
const _LockedHint({required this.text});
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.lock_outline,
|
||||
size: 16, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_options.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart' as model;
|
||||
|
||||
class DeliveryStepOptions extends StatefulWidget {
|
||||
final model.Delivery delivery;
|
||||
|
||||
const DeliveryStepOptions({required this.delivery, super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DeliveryStepInfo();
|
||||
}
|
||||
|
||||
class _DeliveryStepInfo extends State<DeliveryStepOptions> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
debugPrint(
|
||||
"${widget.delivery.options.map((option) => "${option.display}, ${option.value}")}",
|
||||
);
|
||||
return DeliveryOptionsView(
|
||||
options: widget.delivery.options,
|
||||
deliveryId: widget.delivery.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,86 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_state.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/model/note.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_fail_page.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_overview.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
class DeliveryStepNote extends StatefulWidget {
|
||||
final Delivery delivery;
|
||||
|
||||
const DeliveryStepNote({required this.delivery, super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DeliveryStepInfo();
|
||||
}
|
||||
|
||||
class _DeliveryStepInfo extends State<DeliveryStepNote> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<NoteBloc>().add(LoadNote(delivery: widget.delivery));
|
||||
}
|
||||
|
||||
Widget _notesLoading() {
|
||||
return Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
Widget _blocUndefinedState() {
|
||||
return Center(child: const Text("NoteBloc in einem Fehlerhaften Zustand"));
|
||||
}
|
||||
|
||||
Widget _notesOverview(
|
||||
BuildContext context,
|
||||
List<Note> notes,
|
||||
List<NoteTemplate> templates,
|
||||
List<ImageNote> images,
|
||||
) {
|
||||
List<NoteInformation> hydratedNotes =
|
||||
notes
|
||||
.map(
|
||||
(note) => NoteInformation(
|
||||
note: note,
|
||||
article: widget.delivery.findArticleWithNoteId(
|
||||
note.id.toString(),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return NoteOverview(
|
||||
notes: hydratedNotes,
|
||||
deliveryId: widget.delivery.id,
|
||||
templates: templates,
|
||||
images: images,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<NoteBloc, NoteState>(
|
||||
builder: (context, state) {
|
||||
if (state is NoteLoading) {
|
||||
return _notesLoading();
|
||||
}
|
||||
|
||||
if (state is NoteLoaded) {
|
||||
return _notesOverview(
|
||||
context,
|
||||
state.notes,
|
||||
(state.templates ?? []),
|
||||
(state.images ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is NoteLoadingFailed) {
|
||||
return NoteLoadingFailPage(delivery: widget.delivery);
|
||||
}
|
||||
|
||||
return _blocUndefinedState();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
716
lib/feature/delivery/detail/presentation/steps/step_notes.dart
Normal file
716
lib/feature/delivery/detail/presentation/steps/step_notes.dart
Normal file
@ -0,0 +1,716 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
||||
import 'package:hl_lieferservice/widget/attachment_image.dart';
|
||||
|
||||
/// Step 2 — Notizen & Fotos.
|
||||
///
|
||||
/// Die UI trennt bewusst in **zwei Sektionen**, weil es zwei
|
||||
/// unterschiedliche Dinge sind:
|
||||
/// * **Notizen** (Text): anlegen / bearbeiten / löschen über den
|
||||
/// `TourBloc` (Backend-Endpoints vorhanden).
|
||||
/// * **Fotos** (Bild-Notizen): `image_picker` → Upload über
|
||||
/// `TourBloc.UploadDeliveryNoteImage`. Das Backend schiebt das Bild nach
|
||||
/// DOCUframe und legt eine Notiz mit der Referenz an. Fotos werden als
|
||||
/// Thumbnail angezeigt (Tap → formatfüllend) und können nur gelöscht,
|
||||
/// nicht inline bearbeitet werden.
|
||||
///
|
||||
/// Datenmodell: beides sind `DeliveryNote`s. Unterschieden wird über
|
||||
/// `imageAttachment != null` (Foto) bzw. `text != null` (Notiz).
|
||||
class StepNotes extends StatelessWidget {
|
||||
const StepNotes({super.key, required this.delivery, required this.details});
|
||||
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
|
||||
void _openAddNoteDialog(BuildContext context) {
|
||||
final tourBloc = context.read<TourBloc>();
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => _NoteEditorDialog(
|
||||
title: 'Notiz hinzufügen',
|
||||
onSubmit: (text) => tourBloc.add(
|
||||
AddDeliveryNote(deliveryId: delivery.id, text: text),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickImage(BuildContext context) async {
|
||||
// Bloc vor dem await greifen — danach kein context-Zugriff über den
|
||||
// async-Gap.
|
||||
final tourBloc = context.read<TourBloc>();
|
||||
final picker = ImagePicker();
|
||||
// Bild schon on-device runterskalieren + JPEG-komprimieren: Foto-Notizen
|
||||
// brauchen keine 12-MP-Originale. Spart Upload-/Speicher-/Report-Größe
|
||||
// (ein 4080×3060-Foto ~2,9 MB → ~200–400 KB). 1600 px / Q82 deckt sich mit
|
||||
// dem Backend-Report-Renderer.
|
||||
final file = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
maxWidth: 1600,
|
||||
maxHeight: 1600,
|
||||
imageQuality: 82,
|
||||
);
|
||||
if (file == null) return;
|
||||
final bytes = await file.readAsBytes();
|
||||
tourBloc.add(
|
||||
UploadDeliveryNoteImage(
|
||||
deliveryId: delivery.id,
|
||||
filename: file.name,
|
||||
mime: file.mimeType ?? _mimeFromName(file.name),
|
||||
bytes: bytes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Grober MIME-Fallback, wenn der Picker keinen Typ liefert (Kamera gibt
|
||||
/// meist JPEG). Reicht für das `Content-Type` des Multipart-Felds.
|
||||
String _mimeFromName(String name) {
|
||||
final lower = name.toLowerCase();
|
||||
if (lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.heic')) return 'image/heic';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final notes = details.notesOf(delivery.id);
|
||||
// Notizen & Fotos sind nur bei aktiver Lieferung änderbar. Ist die
|
||||
// Lieferung beendet (abgeschlossen/abgebrochen/pausiert), bleiben sie
|
||||
// sichtbar, aber read-only: kein FAB, keine Aktions-Menüs, kein Löschen.
|
||||
final active = delivery.state == DeliveryState.active;
|
||||
// Sauber in Text-Notizen und Fotos aufteilen — getrennte Sektionen.
|
||||
final textNotes =
|
||||
notes.where((n) => n.imageAttachment == null).toList(growable: false);
|
||||
final photoNotes =
|
||||
notes.where((n) => n.imageAttachment != null).toList(growable: false);
|
||||
return Stack(
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
|
||||
children: [
|
||||
if (!active) ...[
|
||||
const _ReadOnlyBanner(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
_SectionHeader(text: 'Notizen (${textNotes.length})'),
|
||||
const SizedBox(height: 8),
|
||||
if (textNotes.isEmpty)
|
||||
const _EmptyHint(
|
||||
icon: Icons.notes,
|
||||
text: 'Noch keine Notizen erfasst.',
|
||||
)
|
||||
else
|
||||
for (final n in textNotes)
|
||||
_NoteCard(note: n, deliveryId: delivery.id, active: active),
|
||||
const SizedBox(height: 24),
|
||||
_SectionHeader(text: 'Fotos (${photoNotes.length})'),
|
||||
const SizedBox(height: 8),
|
||||
if (photoNotes.isEmpty)
|
||||
const _EmptyHint(
|
||||
icon: Icons.photo_camera_outlined,
|
||||
text: 'Noch keine Fotos aufgenommen.',
|
||||
)
|
||||
else
|
||||
for (final n in photoNotes)
|
||||
_PhotoCard(note: n, deliveryId: delivery.id, active: active),
|
||||
],
|
||||
),
|
||||
// FAB nur bei aktiver Lieferung — sonst ist Hinzufügen gesperrt.
|
||||
if (active)
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _AddMenu(
|
||||
onAddNote: () => _openAddNoteDialog(context),
|
||||
onAddImage: () => _pickImage(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hinweis-Balken oben in der Notiz-Sektion, wenn die Lieferung nicht mehr
|
||||
/// aktiv ist — Notizen/Fotos sind dann reine Anzeige.
|
||||
class _ReadOnlyBanner extends StatelessWidget {
|
||||
const _ReadOnlyBanner();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.lock_outline,
|
||||
size: 18, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Lieferung beendet — Notizen & Fotos können nicht mehr '
|
||||
'hinzugefügt, geändert oder gelöscht werden.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.text});
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyHint extends StatelessWidget {
|
||||
const _EmptyHint({required this.icon, required this.text});
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _NoteAction { edit, delete }
|
||||
|
||||
/// Geteiltes Zeitformat für Notiz- und Foto-Karten.
|
||||
String _formatNoteTime(DateTime t) =>
|
||||
'${t.day.toString().padLeft(2, "0")}.${t.month.toString().padLeft(2, "0")}.${t.year} '
|
||||
'${t.hour.toString().padLeft(2, "0")}:${t.minute.toString().padLeft(2, "0")}';
|
||||
|
||||
/// Geteilter Lösch-Bestätigungsdialog. Wording variiert je nachdem, ob eine
|
||||
/// Text-Notiz oder ein Foto entfernt wird; gefeuert wird in beiden Fällen
|
||||
/// dasselbe `DeleteDeliveryNote`-Event (Foto ist intern auch eine Notiz).
|
||||
Future<void> _confirmDeleteNote(
|
||||
BuildContext context, {
|
||||
required String deliveryId,
|
||||
required String noteId,
|
||||
required bool isPhoto,
|
||||
}) async {
|
||||
final tourBloc = context.read<TourBloc>();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(isPhoto ? 'Foto löschen?' : 'Notiz löschen?'),
|
||||
content: Text(
|
||||
isPhoto
|
||||
? 'Das Foto wird dauerhaft entfernt.'
|
||||
: 'Die Notiz wird dauerhaft entfernt.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||
foregroundColor: Theme.of(ctx).colorScheme.onError,
|
||||
),
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Löschen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
tourBloc.add(DeleteDeliveryNote(deliveryId: deliveryId, noteId: noteId));
|
||||
}
|
||||
|
||||
/// Karte einer **Text-Notiz**. Normale Notizen sind bearbeitbar und löschbar.
|
||||
/// **System-verwaltete** Grund-Notizen (Mengen-Gutschrift via
|
||||
/// `creditDeliveryItemId` oder Betrags-Gutschrift via `isAmountCreditNote`)
|
||||
/// dürfen vom Fahrer nicht manuell geändert/gelöscht werden — sie werden
|
||||
/// automatisch mit der jeweiligen Gutschrift angelegt und wieder entfernt.
|
||||
class _NoteCard extends StatelessWidget {
|
||||
const _NoteCard({
|
||||
required this.note,
|
||||
required this.deliveryId,
|
||||
required this.active,
|
||||
});
|
||||
final DeliveryNote note;
|
||||
final String deliveryId;
|
||||
|
||||
/// Nur bei aktiver Lieferung darf bearbeitet/gelöscht werden.
|
||||
final bool active;
|
||||
|
||||
void _openEditDialog(BuildContext context) {
|
||||
final tourBloc = context.read<TourBloc>();
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => _NoteEditorDialog(
|
||||
title: 'Notiz bearbeiten',
|
||||
initialText: note.text,
|
||||
onSubmit: (text) => tourBloc.add(
|
||||
UpdateDeliveryNote(
|
||||
deliveryId: deliveryId,
|
||||
noteId: note.id,
|
||||
text: text,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// System-verwaltete Grund-Notiz: kein manuelles Bearbeiten/Löschen.
|
||||
final isSystemManaged =
|
||||
note.creditDeliveryItemId != null || note.isAmountCreditNote;
|
||||
// Aktions-Menü nur bei aktiver Lieferung UND nicht-System-Notiz.
|
||||
final canEdit = active && !isSystemManaged;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
note.text ?? '',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Personalnr. ${note.authorPersonalnummer} '
|
||||
'· ${_formatNoteTime(note.createdAt)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!canEdit)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8, top: 6),
|
||||
child: Tooltip(
|
||||
message: isSystemManaged
|
||||
? 'Automatisch verwaltet – wird mit der Gutschrift '
|
||||
'angelegt und beim Zurücknehmen wieder entfernt.'
|
||||
: 'Lieferung beendet – Notiz nicht mehr änderbar.',
|
||||
child: Icon(
|
||||
Icons.lock_outline,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
PopupMenuButton<_NoteAction>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
tooltip: 'Notiz-Aktionen',
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _NoteAction.edit:
|
||||
_openEditDialog(context);
|
||||
case _NoteAction.delete:
|
||||
_confirmDeleteNote(
|
||||
context,
|
||||
deliveryId: deliveryId,
|
||||
noteId: note.id,
|
||||
isPhoto: false,
|
||||
);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: _NoteAction.edit,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit_outlined),
|
||||
SizedBox(width: 12),
|
||||
Text('Bearbeiten'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _NoteAction.delete,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete_outline,
|
||||
color: theme.colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Löschen',
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Karte eines **Fotos** — Thumbnail (Tap → formatfüllend) plus Metazeile
|
||||
/// mit Lösch-Button. Kein Inline-Edit (ein Foto bearbeitet man nicht).
|
||||
class _PhotoCard extends StatelessWidget {
|
||||
const _PhotoCard({
|
||||
required this.note,
|
||||
required this.deliveryId,
|
||||
required this.active,
|
||||
});
|
||||
final DeliveryNote note;
|
||||
final String deliveryId;
|
||||
|
||||
/// Nur bei aktiver Lieferung darf das Foto gelöscht werden.
|
||||
final bool active;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_NoteImageThumb(
|
||||
attachmentId: note.imageAttachment!,
|
||||
deleted: note.imageAttachmentDeleted,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 4, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.photo_camera_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Personalnr. ${note.authorPersonalnummer} '
|
||||
'· ${_formatNoteTime(note.createdAt)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (active)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
tooltip: 'Foto löschen',
|
||||
onPressed: () => _confirmDeleteNote(
|
||||
context,
|
||||
deliveryId: deliveryId,
|
||||
noteId: note.id,
|
||||
isPhoto: true,
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Tooltip(
|
||||
message: 'Lieferung beendet – Foto nicht mehr löschbar.',
|
||||
child: Icon(
|
||||
Icons.lock_outline,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Thumbnail einer Bild-Notiz; Tap öffnet das Bild formatfüllend mit
|
||||
/// Zoom/Pan.
|
||||
class _NoteImageThumb extends StatelessWidget {
|
||||
const _NoteImageThumb({required this.attachmentId, this.deleted = false});
|
||||
|
||||
final String attachmentId;
|
||||
|
||||
/// Lokale Bilddatei nach Report-Upload gelöscht → Hinweis statt Vorschau.
|
||||
final bool deleted;
|
||||
|
||||
void _openFull(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: Center(
|
||||
child: InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 5,
|
||||
child: AttachmentImage(
|
||||
attachmentId: attachmentId,
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
quality: 90,
|
||||
fit: BoxFit.contain,
|
||||
deleted: deleted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Kein eigenes Clipping — die umgebende `_PhotoCard` clippt bereits
|
||||
// (Clip.antiAlias), sonst gäbe es doppelt gerundete Ecken.
|
||||
return GestureDetector(
|
||||
// Gelöschtes Bild → kein Vollbild öffnen (es gibt nichts zu laden).
|
||||
onTap: deleted ? null : () => _openFull(context),
|
||||
child: SizedBox(
|
||||
height: 160,
|
||||
width: double.infinity,
|
||||
child: AttachmentImage(
|
||||
attachmentId: attachmentId,
|
||||
width: 600,
|
||||
height: 600,
|
||||
deleted: deleted,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Add-Menu (FAB) ─────────────────────────────────────────────────────
|
||||
|
||||
enum _AddAction { note, image }
|
||||
|
||||
class _AddMenu extends StatelessWidget {
|
||||
const _AddMenu({required this.onAddNote, required this.onAddImage});
|
||||
|
||||
final VoidCallback onAddNote;
|
||||
final VoidCallback onAddImage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton<_AddAction>(
|
||||
tooltip: 'Hinzufügen',
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _AddAction.note:
|
||||
onAddNote();
|
||||
case _AddAction.image:
|
||||
onAddImage();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(
|
||||
value: _AddAction.note,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit_note),
|
||||
SizedBox(width: 12),
|
||||
Text('Notiz schreiben'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _AddAction.image,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.camera_alt_outlined),
|
||||
SizedBox(width: 12),
|
||||
Text('Foto aufnehmen'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: FloatingActionButton.extended(
|
||||
onPressed: null, // PopupMenuButton fängt den Tap
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Hinzufügen'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Notiz-Dialog (Text) ────────────────────────────────────────────────
|
||||
|
||||
/// Editor-Dialog für Text-Notizen — geteilt zwischen „Hinzufügen" und
|
||||
/// „Bearbeiten". Liefert den getrimmten Text per [onSubmit]; der Aufrufer
|
||||
/// entscheidet, ob daraus ein `AddDeliveryNote` oder `UpdateDeliveryNote`
|
||||
/// wird.
|
||||
class _NoteEditorDialog extends StatefulWidget {
|
||||
const _NoteEditorDialog({
|
||||
required this.title,
|
||||
required this.onSubmit,
|
||||
this.initialText,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final void Function(String text) onSubmit;
|
||||
final String? initialText;
|
||||
|
||||
@override
|
||||
State<_NoteEditorDialog> createState() => _NoteEditorDialogState();
|
||||
}
|
||||
|
||||
class _NoteEditorDialogState extends State<_NoteEditorDialog> {
|
||||
late final TextEditingController _controller =
|
||||
TextEditingController(text: widget.initialText ?? '');
|
||||
bool _empty = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_empty = _controller.text.trim().isEmpty;
|
||||
_controller.addListener(() {
|
||||
setState(() => _empty = _controller.text.trim().isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
widget.onSubmit(text);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.55,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// TODO(B6): Templates-Dropdown ergänzen, sobald Backend
|
||||
// Notiz-Templates als Stammdaten anbietet.
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notiz',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: _empty ? null : _save,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('Speichern'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,324 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/service.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
||||
|
||||
/// Step 4 — Services (früher „Lieferoptionen").
|
||||
///
|
||||
/// Rendert die aktiven Service-Definitionen (`TourDetails.services`,
|
||||
/// admin-konfigurierbar) und lässt den Fahrer sie pro Lieferung auswählen:
|
||||
/// `boolean` → Checkbox, `numeric` → Zahlenfeld mit min/max. Werte landen
|
||||
/// über den `TourBloc` (`SetDeliveryServiceValue`/`RemoveDeliveryServiceValue`)
|
||||
/// im Backend. Setzen nur bei aktiver Lieferung.
|
||||
class StepServices extends StatelessWidget {
|
||||
const StepServices({super.key, required this.delivery, required this.details});
|
||||
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
|
||||
String _actorCarId(BuildContext context) {
|
||||
final state = context.read<CarSelectBloc>().state;
|
||||
if (state is CarSelectComplete) return state.selectedCar.id;
|
||||
return '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final active = delivery.state == DeliveryState.active;
|
||||
|
||||
return BlocBuilder<TourBloc, TourState>(
|
||||
buildWhen: (a, b) {
|
||||
if (a is! TourLoaded || b is! TourLoaded) return true;
|
||||
return a.details.services != b.details.services ||
|
||||
a.details.serviceValuesByDeliveryId[delivery.id] !=
|
||||
b.details.serviceValuesByDeliveryId[delivery.id];
|
||||
},
|
||||
builder: (context, state) {
|
||||
final d = state is TourLoaded ? state.details : details;
|
||||
final services = d.services;
|
||||
|
||||
if (services.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.construction_outlined,
|
||||
size: 56, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
Text('Keine Services konfiguriert',
|
||||
style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Ein Administrator kann Services anlegen.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Wie im alten `delivery_options.dart`: zwei Kategorien —
|
||||
// „Auswählbare Optionen" (Checkboxen) und „Zahlenwerte".
|
||||
final bools =
|
||||
services.where((s) => s.kind == ServiceKind.boolean).toList();
|
||||
final numerics =
|
||||
services.where((s) => s.kind == ServiceKind.numeric).toList();
|
||||
|
||||
_ServiceTile tileFor(Service service) => _ServiceTile(
|
||||
service: service,
|
||||
value: d.serviceValueOf(delivery.id, service.id),
|
||||
enabled: active,
|
||||
onSetBool: (v) => context.read<TourBloc>().add(
|
||||
SetDeliveryServiceValue(
|
||||
deliveryId: delivery.id,
|
||||
serviceId: service.id,
|
||||
boolValue: v,
|
||||
actorCarId: _actorCarId(context),
|
||||
),
|
||||
),
|
||||
onSetNumeric: (n) => context.read<TourBloc>().add(
|
||||
SetDeliveryServiceValue(
|
||||
deliveryId: delivery.id,
|
||||
serviceId: service.id,
|
||||
numericValue: n,
|
||||
actorCarId: _actorCarId(context),
|
||||
),
|
||||
),
|
||||
onClear: () => context.read<TourBloc>().add(
|
||||
RemoveDeliveryServiceValue(
|
||||
deliveryId: delivery.id,
|
||||
serviceId: service.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget sectionCard(List<Service> items) => Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < items.length; i++) ...[
|
||||
tileFor(items[i]),
|
||||
if (i < items.length - 1)
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
if (!active)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.lock_outline,
|
||||
size: 16, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Nur bei aktiver Lieferung änderbar.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (bools.isNotEmpty) ...[
|
||||
const _CategoryHeader(
|
||||
icon: Icons.check_box_outlined,
|
||||
text: 'Auswählbare Optionen',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
sectionCard(bools),
|
||||
],
|
||||
if (bools.isNotEmpty && numerics.isNotEmpty)
|
||||
const SizedBox(height: 24),
|
||||
if (numerics.isNotEmpty) ...[
|
||||
const _CategoryHeader(
|
||||
icon: Icons.pin_outlined,
|
||||
text: 'Zahlenwerte',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
sectionCard(numerics),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kategorie-Überschrift (Icon + Titel) — trennt Checkboxen von Zahlenwerten.
|
||||
class _CategoryHeader extends StatelessWidget {
|
||||
const _CategoryHeader({required this.icon, required this.text});
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
text,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Service-Zeile — Checkbox (boolean) oder Zahlenfeld (numeric).
|
||||
class _ServiceTile extends StatelessWidget {
|
||||
const _ServiceTile({
|
||||
required this.service,
|
||||
required this.value,
|
||||
required this.enabled,
|
||||
required this.onSetBool,
|
||||
required this.onSetNumeric,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
final Service service;
|
||||
final DeliveryServiceValue? value;
|
||||
final bool enabled;
|
||||
final ValueChanged<bool> onSetBool;
|
||||
final ValueChanged<int> onSetNumeric;
|
||||
final VoidCallback onClear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (service.kind) {
|
||||
case ServiceKind.boolean:
|
||||
return CheckboxListTile(
|
||||
value: value?.boolValue ?? false,
|
||||
onChanged: enabled ? (v) => onSetBool(v ?? false) : null,
|
||||
title: Text(service.name),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
dense: true,
|
||||
);
|
||||
case ServiceKind.numeric:
|
||||
return _NumericServiceField(
|
||||
key: ValueKey(service.id),
|
||||
service: service,
|
||||
initial: value?.numericValue,
|
||||
enabled: enabled,
|
||||
onSetNumeric: onSetNumeric,
|
||||
onClear: onClear,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zahlenfeld eines numerischen Service — eigener Controller, persistiert beim
|
||||
/// Verlassen/Submit, klemmt auf [min,max]. Leeres Feld → Wert entfernen.
|
||||
class _NumericServiceField extends StatefulWidget {
|
||||
const _NumericServiceField({
|
||||
super.key,
|
||||
required this.service,
|
||||
required this.initial,
|
||||
required this.enabled,
|
||||
required this.onSetNumeric,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
final Service service;
|
||||
final int? initial;
|
||||
final bool enabled;
|
||||
final ValueChanged<int> onSetNumeric;
|
||||
final VoidCallback onClear;
|
||||
|
||||
@override
|
||||
State<_NumericServiceField> createState() => _NumericServiceFieldState();
|
||||
}
|
||||
|
||||
class _NumericServiceFieldState extends State<_NumericServiceField> {
|
||||
late final TextEditingController _controller =
|
||||
TextEditingController(text: widget.initial?.toString() ?? '');
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_NumericServiceField old) {
|
||||
super.didUpdateWidget(old);
|
||||
// Server-Stand übernehmen, wenn er sich geändert hat (z. B. nach
|
||||
// Reconcile) und sich vom angezeigten Text unterscheidet.
|
||||
final incoming = widget.initial?.toString() ?? '';
|
||||
if (old.initial != widget.initial && _controller.text != incoming) {
|
||||
_controller.text = incoming;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _commit() {
|
||||
final raw = _controller.text.trim();
|
||||
if (raw.isEmpty) {
|
||||
widget.onClear();
|
||||
return;
|
||||
}
|
||||
final parsed = int.tryParse(raw);
|
||||
if (parsed == null) {
|
||||
_controller.text = widget.initial?.toString() ?? '';
|
||||
return;
|
||||
}
|
||||
var n = parsed;
|
||||
final min = widget.service.minValue;
|
||||
final max = widget.service.maxValue;
|
||||
if (min != null && n < min) n = min;
|
||||
if (max != null && n > max) n = max;
|
||||
if (n.toString() != _controller.text) {
|
||||
_controller.text = n.toString();
|
||||
}
|
||||
widget.onSetNumeric(n);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = widget.service;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
enabled: widget.enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
labelText: s.name,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onTapOutside: (_) {
|
||||
FocusScope.of(context).unfocus();
|
||||
_commit();
|
||||
},
|
||||
onSubmitted: (_) => _commit(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,19 +1,514 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_summary.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart';
|
||||
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
|
||||
|
||||
/// Step 5 — Übersicht & Abschluss.
|
||||
///
|
||||
/// Listet alle Artikel mit der **tatsächlich auszuliefernden Menge** auf
|
||||
/// (Original-Soll minus lokaler Partial-Remove-Drafts minus
|
||||
/// Komplett-Removes). Dazu Anzahlung-Anzeige, optionale Gutschrift,
|
||||
/// Zahlungsmethoden-Dropdown.
|
||||
///
|
||||
/// Der „Unterschreiben"-Button lebt in der Bottom-Navigation des
|
||||
/// Page-Wrappers; hier zeigen wir den Resümee-Block, der direkt vor der
|
||||
/// Unterschrift steht.
|
||||
class StepSummary extends StatelessWidget {
|
||||
const StepSummary({
|
||||
super.key,
|
||||
required this.delivery,
|
||||
required this.details,
|
||||
});
|
||||
|
||||
class DeliveryStepSummary extends StatefulWidget {
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
|
||||
const DeliveryStepSummary({required this.delivery, super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DeliveryStepInfo();
|
||||
}
|
||||
|
||||
class _DeliveryStepInfo extends State<DeliveryStepSummary> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DeliverySummary(delivery: widget.delivery);
|
||||
return BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
|
||||
builder: (context, wfState) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
_SectionHeader(text: 'Ausgelieferte Artikel'),
|
||||
const SizedBox(height: 8),
|
||||
_DeliveredItems(
|
||||
delivery: delivery,
|
||||
details: details,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SectionHeader(text: 'Zahlung'),
|
||||
const SizedBox(height: 8),
|
||||
_PaymentSummary(
|
||||
delivery: delivery,
|
||||
credit: details.creditOf(delivery.id),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SectionHeader(text: 'Zahlungsmethode'),
|
||||
const SizedBox(height: 8),
|
||||
_PaymentMethodPicker(
|
||||
delivery: delivery,
|
||||
overrideId: wfState.paymentMethodOverrideId,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _SignHint(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.text});
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeliveredItems extends StatelessWidget {
|
||||
const _DeliveredItems({
|
||||
required this.delivery,
|
||||
required this.details,
|
||||
});
|
||||
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// Innerhalb einer Belegzeile: Oberartikel vor seinen Komponenten, damit
|
||||
// Komponenten direkt darunter eingerückt erscheinen.
|
||||
final items = List<DeliveryItem>.of(delivery.items)
|
||||
..sort((a, b) {
|
||||
final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr);
|
||||
if (byLine != 0) return byLine;
|
||||
final byParent =
|
||||
(a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0);
|
||||
if (byParent != 0) return byParent;
|
||||
return (a.komponentenArtikelNr ?? '')
|
||||
.compareTo(b.komponentenArtikelNr ?? '');
|
||||
});
|
||||
if (items.isEmpty) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Keine Artikel hinterlegt.',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < items.length; i++) ...[
|
||||
_DeliveredRow(
|
||||
item: items[i],
|
||||
details: details,
|
||||
),
|
||||
if (i < items.length - 1)
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeliveredRow extends StatelessWidget {
|
||||
const _DeliveredRow({
|
||||
required this.item,
|
||||
required this.details,
|
||||
});
|
||||
|
||||
final DeliveryItem item;
|
||||
final TourDetails details;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final article = details.articleOf(item.articleId);
|
||||
// Ausgeliefert = Soll − Gutschrift (vom Backend). Voll gutgeschrieben
|
||||
// (status removed) ⇒ credited == required ⇒ delivered 0.
|
||||
final credited = item.scanProgress.creditedQuantity;
|
||||
final delivered = (item.requiredQuantity - credited).clamp(
|
||||
0,
|
||||
item.requiredQuantity,
|
||||
);
|
||||
|
||||
final Color avatarColor;
|
||||
if (delivered == 0) {
|
||||
avatarColor = Colors.red.shade400;
|
||||
} else if (delivered < item.requiredQuantity) {
|
||||
avatarColor = Colors.amber.shade700;
|
||||
} else {
|
||||
avatarColor = Colors.green.shade600;
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
// Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber).
|
||||
contentPadding:
|
||||
EdgeInsets.only(left: item.isComponent ? 40 : 16, right: 16),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: avatarColor,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
child: Text(
|
||||
'$delivered×',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
'${item.isComponent ? '↳ ' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
|
||||
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (delivered < item.requiredQuantity)
|
||||
'von ${item.requiredQuantity} bestellt · Gutschrift: $credited'
|
||||
else
|
||||
'Artikelnr. ${article?.articleNumber ?? item.articleId}',
|
||||
'${item.unitPrice.toStringAsFixed(2)} € / Stück',
|
||||
].join(' · '),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
'${item.lineTotal.toStringAsFixed(2)} €',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
|
||||
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaymentSummary extends StatelessWidget {
|
||||
const _PaymentSummary({required this.delivery, required this.credit});
|
||||
|
||||
final Delivery delivery;
|
||||
final DeliveryCredit? credit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// Exakt aus Cent (nicht gerundet) — Gutschrift kann Cent-Beträge haben.
|
||||
final creditEuros = (credit?.amountCents ?? 0) / 100.0;
|
||||
// Warenwert = Σ Stückpreis × ausgelieferte Menge (entfernte/teil-entfernte
|
||||
// Positionen fallen automatisch raus).
|
||||
final warenwert = delivery.items
|
||||
.fold<double>(0, (acc, item) => acc + item.lineTotal);
|
||||
// Offener Betrag = Warenwert − Anzahlung − Gutschrift, nie negativ.
|
||||
final open = (warenwert - delivery.prepaidAmount - creditEuros)
|
||||
.clamp(0.0, double.infinity);
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
_SummaryRow(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
label: 'Warenwert',
|
||||
valueText: '${warenwert.toStringAsFixed(2)} €',
|
||||
valueColor: theme.colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SummaryRow(
|
||||
icon: Icons.savings_outlined,
|
||||
label: 'Bei Bestellung bezahlt',
|
||||
valueText: '− ${delivery.prepaidAmount.toStringAsFixed(2)} €',
|
||||
valueColor: delivery.prepaidAmount > 0
|
||||
? Colors.green.shade700
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
if (credit != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_SummaryRow(
|
||||
icon: Icons.card_giftcard_outlined,
|
||||
label: 'Gutschrift',
|
||||
valueText: '− ${(credit!.amountCents / 100).toStringAsFixed(2)} €',
|
||||
valueColor: Colors.amber.shade800,
|
||||
subtitle: credit!.reason,
|
||||
),
|
||||
],
|
||||
const Divider(height: 24),
|
||||
_SummaryRow(
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
label: 'Offener Betrag',
|
||||
valueText: '${open.toStringAsFixed(2)} €',
|
||||
valueColor: open > 0
|
||||
? theme.colorScheme.primary
|
||||
: Colors.green.shade700,
|
||||
emphasize: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryRow extends StatelessWidget {
|
||||
const _SummaryRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.valueText,
|
||||
required this.valueColor,
|
||||
this.subtitle,
|
||||
this.emphasize = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String valueText;
|
||||
final Color valueColor;
|
||||
final String? subtitle;
|
||||
|
||||
/// Hebt Label + Wert hervor (für den „Offener Betrag"-Abschluss).
|
||||
final bool emphasize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Icon(icon, color: theme.colorScheme.primary),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: emphasize
|
||||
? const TextStyle(fontWeight: FontWeight.w700)
|
||||
: null,
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
valueText,
|
||||
style: (emphasize
|
||||
? theme.textTheme.titleLarge
|
||||
: theme.textTheme.titleMedium)
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaymentMethodPicker extends StatelessWidget {
|
||||
const _PaymentMethodPicker({
|
||||
required this.delivery,
|
||||
required this.overrideId,
|
||||
});
|
||||
|
||||
final Delivery delivery;
|
||||
final String? overrideId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<PaymentMethodsCubit, PaymentMethodsState>(
|
||||
builder: (context, state) {
|
||||
if (state is PaymentMethodsLoading || state is PaymentMethodsInitial) {
|
||||
return const Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Text('Zahlungsmethoden laden …'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is PaymentMethodsFailed) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
state.message,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final loaded = state as PaymentMethodsLoaded;
|
||||
// Ausschließlich die Backend-Methoden — keine frontend-seitige
|
||||
// Fabrikation/Hardcodierung. Es werden genau die angezeigt, die im
|
||||
// Backend (Postgres `payment_methods`, aktiv) hinterlegt sind.
|
||||
final methods = loaded.methods;
|
||||
final selectedId = overrideId ?? delivery.paymentMethodId;
|
||||
// Als Dropdown-Value nur setzen, wenn die Methode tatsächlich in der
|
||||
// Backend-Liste ist (sonst würde Flutter asserten). Ist die zugewiesene
|
||||
// Methode zwischenzeitlich deaktiviert/entfernt, bleibt das Feld leer.
|
||||
final selectedValue =
|
||||
methods.any((m) => m.id == selectedId) ? selectedId : null;
|
||||
// Zahlungsmethode nur bei aktiver Lieferung änderbar. Bei
|
||||
// abgeschlossener/abgebrochener/pausierter Lieferung zeigt das
|
||||
// Dropdown den gewählten Stand, ist aber gesperrt.
|
||||
final active = delivery.state == DeliveryState.active;
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: selectedValue,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Zahlungsmethode',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (final m in methods)
|
||||
DropdownMenuItem(
|
||||
value: m.id,
|
||||
child: Text(m.name),
|
||||
),
|
||||
],
|
||||
// `null` deaktiviert das Dropdown (Flutter-Konvention).
|
||||
onChanged: active
|
||||
? (newId) {
|
||||
if (newId == null) return;
|
||||
context.read<DeliveryWorkflowBloc>().add(
|
||||
WorkflowOverridePaymentMethod(
|
||||
// Zurück auf die Original-Methode → Override
|
||||
// löschen, damit das Domain-Modell "no
|
||||
// override" kennt.
|
||||
paymentMethodId:
|
||||
newId == delivery.paymentMethodId
|
||||
? null
|
||||
: newId,
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
if (!active) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.lock_outline,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Lieferung abgeschlossen — Zahlungsmethode nicht '
|
||||
'mehr änderbar.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SignHint extends StatelessWidget {
|
||||
const _SignHint();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.07),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.draw_outlined, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Mit „Unterschreiben" unten schließt der Kunde den Vorgang ab.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>[];
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user