1256 lines
43 KiB
Dart
1256 lines
43 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:uuid/uuid.dart';
|
||
|
||
import 'package:hl_lieferservice/data/cache/attachment_cache.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/delivery_note.dart';
|
||
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
|
||
import 'package:hl_lieferservice/domain/entity/scan_intent.dart';
|
||
import 'package:hl_lieferservice/domain/entity/scan_progress.dart';
|
||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||
import 'package:hl_lieferservice/domain/repository/tour_repository.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/operations/bloc/operation_bloc.dart';
|
||
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
|
||
|
||
/// Bloc für die heutige Tour. Phase C+D-2: nur Lese-Pfad + Reorder +
|
||
/// Car-Assign. Spätere Schreib-Aktionen (Scan/Hold/Cancel/Complete/Notes)
|
||
/// landen in einem späteren Schritt **hier** und nicht in einem
|
||
/// Parallel-Bloc — der Plan ist ein Bloc pro Aggregat, nicht pro Aktion.
|
||
class TourBloc extends Bloc<TourEvent, TourState> {
|
||
TourBloc({
|
||
required this.tourRepository,
|
||
required this.opBloc,
|
||
required this.attachmentCache,
|
||
}) : super(const TourInitial()) {
|
||
on<LoadTour>(_onLoad);
|
||
on<RefreshTour>(_onRefresh);
|
||
on<ReorderDeliveries>(_onReorder);
|
||
on<AssignCarToDelivery>(_onAssignCar);
|
||
on<AssignCarToDeliveries>(_onAssignCarBulk);
|
||
on<ScanItem>(_onScanItem);
|
||
on<UnscanItem>(_onUnscanItem);
|
||
on<HoldItem>(_onHoldItem);
|
||
on<UnholdItem>(_onUnholdItem);
|
||
on<RemoveItem>(_onRemoveItem);
|
||
on<UnremoveItem>(_onUnremoveItem);
|
||
on<CancelDelivery>(_onCancelDelivery);
|
||
on<HoldDelivery>(_onHoldDelivery);
|
||
on<ResumeDelivery>(_onResumeDelivery);
|
||
on<CompleteDelivery>(_onCompleteDelivery);
|
||
on<AddDeliveryNote>(_onAddDeliveryNote);
|
||
on<UpdateDeliveryNote>(_onUpdateDeliveryNote);
|
||
on<DeleteDeliveryNote>(_onDeleteDeliveryNote);
|
||
on<UploadDeliveryNoteImage>(_onUploadDeliveryNoteImage);
|
||
on<SetDeliveryCredit>(_onSetDeliveryCredit);
|
||
on<RemoveDeliveryCredit>(_onRemoveDeliveryCredit);
|
||
on<SetDeliveryServiceValue>(_onSetDeliveryServiceValue);
|
||
on<RemoveDeliveryServiceValue>(_onRemoveDeliveryServiceValue);
|
||
}
|
||
|
||
final TourRepository tourRepository;
|
||
final OperationBloc opBloc;
|
||
|
||
/// Disk-Cache für Attachment-Vorschauen. Der Bloc nutzt ihn nur fürs
|
||
/// **Pruning**: bei jedem frischen Tour-Load wird der Cache auf die in der
|
||
/// Tour referenzierten Bild-IDs eingedampft, sodass Vorschauen gelöschter
|
||
/// Foto-Notizen lokal verschwinden.
|
||
final AttachmentCache attachmentCache;
|
||
|
||
final Uuid _uuid = const Uuid();
|
||
|
||
/// Räumt verwaiste Cache-Bilder weg, sobald frische Tour-Daten vorliegen.
|
||
/// Fire-and-forget — Cache-Pflege darf den Load nie verzögern oder
|
||
/// scheitern lassen.
|
||
void _pruneAttachmentCache(TourDetails details) {
|
||
unawaited(attachmentCache.retainOnly(details.referencedAttachmentIds));
|
||
}
|
||
|
||
// ─── LoadTour ────────────────────────────────────────────────────────
|
||
|
||
Future<void> _onLoad(LoadTour event, Emitter<TourState> emit) async {
|
||
emit(const TourLoading());
|
||
try {
|
||
final details = await tourRepository.getMyTourDetailsOfToday();
|
||
if (details == null) {
|
||
emit(const TourEmpty());
|
||
return;
|
||
}
|
||
emit(TourLoaded(details: details));
|
||
_pruneAttachmentCache(details);
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc.LoadTour fehlgeschlagen: $e\n$st');
|
||
final message = _messageOf(e, 'Tour konnte nicht geladen werden');
|
||
emit(TourLoadFailed(message: message));
|
||
opBloc.add(FailOperation(message: message));
|
||
}
|
||
}
|
||
|
||
// ─── RefreshTour ─────────────────────────────────────────────────────
|
||
|
||
Future<void> _onRefresh(RefreshTour event, Emitter<TourState> emit) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) {
|
||
// Keine sichtbare Tour zum „weichen" Refreshen — wie ein LoadTour
|
||
// behandeln.
|
||
await _onLoad(const LoadTour(), emit);
|
||
return;
|
||
}
|
||
|
||
emit(current.copyWith(isRefreshing: true, refreshError: null));
|
||
try {
|
||
final details = await tourRepository.getMyTourDetailsOfToday();
|
||
if (details == null) {
|
||
emit(const TourEmpty());
|
||
return;
|
||
}
|
||
emit(TourLoaded(details: details));
|
||
_pruneAttachmentCache(details);
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc.RefreshTour fehlgeschlagen: $e\n$st');
|
||
final message = _messageOf(e, 'Tour konnte nicht neu geladen werden');
|
||
// alten Stand sichtbar lassen, Fehler oben mitführen
|
||
final latest = state;
|
||
if (latest is TourLoaded) {
|
||
emit(latest.copyWith(isRefreshing: false, refreshError: message));
|
||
} else {
|
||
emit(TourLoadFailed(message: message));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── ReorderDeliveries ───────────────────────────────────────────────
|
||
|
||
Future<void> _onReorder(
|
||
ReorderDeliveries event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
emit(current.copyWith(isPersistingReorder: true, reorderError: null));
|
||
try {
|
||
final updated = await tourRepository.setDeliveryOrder(
|
||
tourId: current.details.tour.id,
|
||
orderedDeliveryIds: event.orderedDeliveryIds,
|
||
);
|
||
|
||
final patched = _applySortOrder(current.details, updated);
|
||
emit(TourLoaded(
|
||
details: patched,
|
||
isPersistingReorder: false,
|
||
));
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc.ReorderDeliveries fehlgeschlagen: $e\n$st');
|
||
final message = _messageOf(
|
||
e,
|
||
'Reihenfolge konnte nicht gespeichert werden',
|
||
);
|
||
final latest = state;
|
||
if (latest is TourLoaded) {
|
||
emit(latest.copyWith(
|
||
isPersistingReorder: false,
|
||
reorderError: message,
|
||
));
|
||
}
|
||
opBloc.add(FailOperation(message: message));
|
||
}
|
||
}
|
||
|
||
TourDetails _applySortOrder(
|
||
TourDetails details,
|
||
Map<String, int> newOrderByDeliveryId,
|
||
) {
|
||
final next = <Delivery>[];
|
||
for (final d in details.deliveries) {
|
||
final newOrder = newOrderByDeliveryId[d.id];
|
||
next.add(newOrder == null ? d : d.copyWith(sortOrder: newOrder));
|
||
}
|
||
return details.copyWith(deliveries: next);
|
||
}
|
||
|
||
// ─── AssignCarToDelivery ─────────────────────────────────────────────
|
||
|
||
Future<void> _onAssignCar(
|
||
AssignCarToDelivery event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
opBloc.add(StartOperation());
|
||
try {
|
||
final remoteDelivery = await tourRepository.assignCarToDelivery(
|
||
deliveryId: event.deliveryId,
|
||
carId: event.carId,
|
||
);
|
||
|
||
// Server schickt die Delivery ohne Items zurück (Phase-1-Endpoint).
|
||
// Wir bauen die lokale Delivery so um, dass alle Stamm-Felder vom
|
||
// Server übernommen werden, Items und sortOrder aus dem lokalen
|
||
// Aggregat erhalten bleiben.
|
||
final localDelivery = current.details.deliveries
|
||
.firstWhere((d) => d.id == event.deliveryId, orElse: () => remoteDelivery);
|
||
|
||
final merged = remoteDelivery.copyWith(
|
||
sortOrder: localDelivery.sortOrder,
|
||
items: localDelivery.items,
|
||
);
|
||
|
||
emit(current.copyWith(details: current.details.replaceDelivery(merged)));
|
||
opBloc.add(FinishOperation());
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc.AssignCarToDelivery fehlgeschlagen: $e\n$st');
|
||
opBloc.add(FailOperation(
|
||
message: _messageOf(e, 'Fahrzeug-Zuweisung fehlgeschlagen'),
|
||
));
|
||
}
|
||
}
|
||
|
||
// ─── AssignCarToDeliveries (Bulk) ────────────────────────────────────
|
||
|
||
/// Sequenzielle Variante von [_onAssignCar]: arbeitet die Liste der
|
||
/// Lieferungs-Ids in der gegebenen Reihenfolge ab, merged jedes
|
||
/// Server-Update mit dem Items-/sortOrder-Stand aus dem lokalen
|
||
/// Aggregat und emittiert genau **einen** State am Ende. Damit ist die
|
||
/// Aktion atomar — entweder sieht das UI alle erfolgreichen Zuweisungen
|
||
/// auf einmal, oder bei einem Fehler die bis dahin erfolgreichen
|
||
/// (Backend hat sie bereits committed) zusammen mit einer Fail-Meldung.
|
||
Future<void> _onAssignCarBulk(
|
||
AssignCarToDeliveries event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
if (event.deliveryIds.isEmpty) return;
|
||
|
||
opBloc.add(StartOperation());
|
||
|
||
var workingDetails = current.details;
|
||
var successCount = 0;
|
||
String? failureMessage;
|
||
|
||
for (final deliveryId in event.deliveryIds) {
|
||
try {
|
||
final remote = await tourRepository.assignCarToDelivery(
|
||
deliveryId: deliveryId,
|
||
carId: event.carId,
|
||
);
|
||
final local = workingDetails.deliveries.firstWhere(
|
||
(d) => d.id == deliveryId,
|
||
orElse: () => remote,
|
||
);
|
||
final merged = remote.copyWith(
|
||
sortOrder: local.sortOrder,
|
||
items: local.items,
|
||
);
|
||
workingDetails = workingDetails.replaceDelivery(merged);
|
||
successCount++;
|
||
} catch (e, st) {
|
||
debugPrint(
|
||
'TourBloc.AssignCarToDeliveries[$deliveryId] fehlgeschlagen: $e\n$st',
|
||
);
|
||
failureMessage = _messageOf(e, 'Fahrzeug-Zuweisung fehlgeschlagen');
|
||
break;
|
||
}
|
||
}
|
||
|
||
emit(current.copyWith(details: workingDetails));
|
||
|
||
if (failureMessage != null) {
|
||
opBloc.add(FailOperation(
|
||
message: successCount == 0
|
||
? failureMessage
|
||
: '$successCount von ${event.deliveryIds.length} Lieferungen '
|
||
'zugewiesen — $failureMessage',
|
||
));
|
||
} else {
|
||
opBloc.add(FinishOperation());
|
||
}
|
||
}
|
||
|
||
// ─── ScanItem / UnscanItem ───────────────────────────────────────────
|
||
|
||
/// Plus-Eins-Scan: lokal sofort hochzählen, anschließend Backend
|
||
/// bestätigen lassen. Bei `rejected` zurückrollen, bei `duplicate`
|
||
/// still bleiben (Server-/Client-State stimmen schon überein).
|
||
Future<void> _onScanItem(ScanItem event, Emitter<TourState> emit) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
final beforeItem = _findItem(current.details, event.deliveryItemId);
|
||
if (beforeItem == null) {
|
||
opBloc.add(FailOperation(
|
||
message: 'Artikel nicht in der aktuellen Tour gefunden',
|
||
));
|
||
return;
|
||
}
|
||
if (beforeItem.isDone) {
|
||
// Defensive: UI sollte „done" gar nicht zum Scan zulassen. Falls doch,
|
||
// hier still skippen statt einen rejected vom Server zu provozieren.
|
||
opBloc.add(FailOperation(message: 'Artikel ist bereits vollständig'));
|
||
return;
|
||
}
|
||
|
||
// Manuelle Bestätigung verbucht die GANZE Restmenge auf einmal; der
|
||
// reguläre (Barcode-)Scan zählt um +1 hoch.
|
||
final remaining =
|
||
beforeItem.requiredQuantity - beforeItem.scanProgress.scannedQuantity;
|
||
final delta = event.manual ? remaining : 1;
|
||
final newQuantity = beforeItem.scanProgress.scannedQuantity + delta;
|
||
|
||
// ── Optimistisches Update ──
|
||
final optimisticItem = beforeItem.copyWith(
|
||
scanProgress: beforeItem.scanProgress.copyWith(
|
||
scannedQuantity: newQuantity,
|
||
lastUpdatedAt: DateTime.now(),
|
||
status: newQuantity >= beforeItem.requiredQuantity
|
||
? ScanStatus.done
|
||
: ScanStatus.inProgress,
|
||
),
|
||
);
|
||
emit(current.copyWith(
|
||
details: _replaceItem(current.details, optimisticItem),
|
||
));
|
||
|
||
// ── Server-Apply ──
|
||
// Bei manueller Bestätigung schickt die App die Restmenge als `quantity`
|
||
// mit (Backend verbucht sie als ein Scan-Event, `manual=true`).
|
||
final intent = ScanIntent(
|
||
clientScanId: _uuid.v4(),
|
||
clientScannedAt: DateTime.now(),
|
||
deliveryItemId: event.deliveryItemId,
|
||
action: ScanAction.scan,
|
||
actorCarId: event.actorCarId,
|
||
quantity: event.manual ? remaining : null,
|
||
manual: event.manual,
|
||
);
|
||
await _applyAndReconcile(emit, intent, beforeItem);
|
||
}
|
||
|
||
Future<void> _onUnscanItem(
|
||
UnscanItem event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
final beforeItem = _findItem(current.details, event.deliveryItemId);
|
||
if (beforeItem == null) return;
|
||
if (beforeItem.scanProgress.scannedQuantity == 0) {
|
||
opBloc.add(FailOperation(
|
||
message: 'Artikel hat keine Scans zum Zurücknehmen',
|
||
));
|
||
return;
|
||
}
|
||
|
||
final optimisticItem = beforeItem.copyWith(
|
||
scanProgress: beforeItem.scanProgress.copyWith(
|
||
scannedQuantity: beforeItem.scanProgress.scannedQuantity - 1,
|
||
lastUpdatedAt: DateTime.now(),
|
||
status: ScanStatus.inProgress,
|
||
),
|
||
);
|
||
emit(current.copyWith(
|
||
details: _replaceItem(current.details, optimisticItem),
|
||
));
|
||
|
||
final intent = ScanIntent(
|
||
clientScanId: _uuid.v4(),
|
||
clientScannedAt: DateTime.now(),
|
||
deliveryItemId: event.deliveryItemId,
|
||
action: ScanAction.unscan,
|
||
actorCarId: event.actorCarId,
|
||
reason: event.reason,
|
||
);
|
||
await _applyAndReconcile(emit, intent, beforeItem);
|
||
}
|
||
|
||
Future<void> _onHoldItem(HoldItem event, Emitter<TourState> emit) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
final beforeItem = _findItem(current.details, event.deliveryItemId);
|
||
if (beforeItem == null) return;
|
||
|
||
final optimisticItem = beforeItem.copyWith(
|
||
scanProgress: beforeItem.scanProgress.copyWith(
|
||
status: ScanStatus.held,
|
||
heldReason: event.reason,
|
||
lastUpdatedAt: DateTime.now(),
|
||
),
|
||
);
|
||
emit(current.copyWith(
|
||
details: _replaceItem(current.details, optimisticItem),
|
||
));
|
||
|
||
final intent = ScanIntent(
|
||
clientScanId: _uuid.v4(),
|
||
clientScannedAt: DateTime.now(),
|
||
deliveryItemId: event.deliveryItemId,
|
||
action: ScanAction.hold,
|
||
actorCarId: event.actorCarId,
|
||
reason: event.reason,
|
||
);
|
||
await _applyAndReconcile(emit, intent, beforeItem);
|
||
}
|
||
|
||
Future<void> _onUnholdItem(
|
||
UnholdItem event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
final beforeItem = _findItem(current.details, event.deliveryItemId);
|
||
if (beforeItem == null) return;
|
||
|
||
// Beim Unhold lokal zurück auf in_progress; `done` wird beim nächsten
|
||
// Server-Refresh ggf. neu gesetzt, falls die scannedQuantity schon
|
||
// requiredQuantity erreicht hatte.
|
||
final optimisticItem = beforeItem.copyWith(
|
||
scanProgress: ScanProgress(
|
||
status: beforeItem.scanProgress.scannedQuantity >=
|
||
beforeItem.requiredQuantity
|
||
? ScanStatus.done
|
||
: ScanStatus.inProgress,
|
||
scannedQuantity: beforeItem.scanProgress.scannedQuantity,
|
||
lastUpdatedAt: DateTime.now(),
|
||
// heldReason explizit weglassen — Backend löscht ihn auch.
|
||
),
|
||
);
|
||
emit(current.copyWith(
|
||
details: _replaceItem(current.details, optimisticItem),
|
||
));
|
||
|
||
final intent = ScanIntent(
|
||
clientScanId: _uuid.v4(),
|
||
clientScannedAt: DateTime.now(),
|
||
deliveryItemId: event.deliveryItemId,
|
||
action: ScanAction.unhold,
|
||
actorCarId: event.actorCarId,
|
||
);
|
||
await _applyAndReconcile(emit, intent, beforeItem);
|
||
}
|
||
|
||
Future<void> _onRemoveItem(
|
||
RemoveItem event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
final beforeItem = _findItem(current.details, event.deliveryItemId);
|
||
if (beforeItem == null) return;
|
||
|
||
// Optimistische Mengen-Gutschrift: `quantity == null` heißt „ganze
|
||
// Restmenge". Status kippt erst auf `removed`, wenn voll gutgeschrieben.
|
||
final before = beforeItem.scanProgress;
|
||
final remaining = beforeItem.requiredQuantity - before.creditedQuantity;
|
||
final n = event.quantity ?? remaining;
|
||
final newCredited =
|
||
(before.creditedQuantity + n).clamp(0, beforeItem.requiredQuantity);
|
||
final fully = newCredited >= beforeItem.requiredQuantity;
|
||
|
||
final optimisticItem = beforeItem.copyWith(
|
||
scanProgress: before.copyWith(
|
||
creditedQuantity: newCredited,
|
||
status: fully ? ScanStatus.removed : before.status,
|
||
lastUpdatedAt: DateTime.now(),
|
||
),
|
||
);
|
||
emit(current.copyWith(
|
||
details: _replaceItem(current.details, optimisticItem),
|
||
));
|
||
|
||
final intent = ScanIntent(
|
||
clientScanId: _uuid.v4(),
|
||
clientScannedAt: DateTime.now(),
|
||
deliveryItemId: event.deliveryItemId,
|
||
action: ScanAction.remove,
|
||
actorCarId: event.actorCarId,
|
||
reason: event.reason,
|
||
quantity: event.quantity,
|
||
);
|
||
final applied = await _applyAndReconcile(emit, intent, beforeItem);
|
||
|
||
// Oberartikel voll entfernt → seine Komponenten lokal ebenfalls als
|
||
// entfernt zeigen. Das Backend hat sie in derselben Transaktion bereits
|
||
// cascade-entfernt; hier ziehen wir nur die UI nach (der Scan-Response
|
||
// liefert nur den State der einen Position). Erst nach bestätigtem
|
||
// `applied`, damit ein abgelehnter Versuch die Komponenten nicht fälschlich
|
||
// ausblendet.
|
||
if (applied && fully) {
|
||
final s = state;
|
||
if (s is TourLoaded) {
|
||
final parentNr =
|
||
s.details.articleOf(beforeItem.articleId)?.articleNumber;
|
||
Delivery? delivery;
|
||
for (final d in s.details.deliveries) {
|
||
if (d.id == beforeItem.deliveryId) {
|
||
delivery = d;
|
||
break;
|
||
}
|
||
}
|
||
if (parentNr != null && delivery != null) {
|
||
var details = s.details;
|
||
for (final it in delivery.items) {
|
||
if (it.parentArtikelNr == parentNr &&
|
||
it.belegzeilenNr == beforeItem.belegzeilenNr &&
|
||
it.scanProgress.status != ScanStatus.removed) {
|
||
details = _replaceItem(
|
||
details,
|
||
it.copyWith(
|
||
scanProgress: it.scanProgress.copyWith(
|
||
creditedQuantity: it.requiredQuantity,
|
||
status: ScanStatus.removed,
|
||
lastUpdatedAt: DateTime.now(),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
emit(s.copyWith(details: details));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Grund der Gutschrift zusätzlich als Notiz festhalten — aber nur, wenn
|
||
// das Entfernen wirklich durchging (kein Note-Eintrag für abgelehnte
|
||
// Versuche) und der Aufrufer das angefordert hat (Gutschrift-Flow).
|
||
if (applied && event.saveReasonAsNote) {
|
||
final articleName =
|
||
current.details.articleOf(beforeItem.articleId)?.name ??
|
||
'Artikel';
|
||
add(AddDeliveryNote(
|
||
deliveryId: beforeItem.deliveryId,
|
||
text: 'Gutschrift – $articleName ($n×): ${event.reason}',
|
||
// Verknüpfung zur Belegzeile → beim Unremove gezielt löschbar.
|
||
creditDeliveryItemId: beforeItem.id,
|
||
));
|
||
}
|
||
}
|
||
|
||
Future<void> _onUnremoveItem(
|
||
UnremoveItem event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
final beforeItem = _findItem(current.details, event.deliveryItemId);
|
||
if (beforeItem == null) return;
|
||
|
||
// Optimistische Rücknahme der Gutschrift. `quantity == null` = alles
|
||
// zurück. Stand die Zeile auf `removed` (voll gutgeschrieben), kommt sie
|
||
// nach Scan-Menge zurück auf `done`/`in_progress`; bei Teil-Rücknahme
|
||
// bleibt der Status. `heldReason` wird gelöscht (analog Backend).
|
||
final before = beforeItem.scanProgress;
|
||
final n = event.quantity ?? before.creditedQuantity;
|
||
final newCredited =
|
||
(before.creditedQuantity - n).clamp(0, beforeItem.requiredQuantity);
|
||
final newStatus = before.status == ScanStatus.removed
|
||
? (before.scannedQuantity >= beforeItem.requiredQuantity
|
||
? ScanStatus.done
|
||
: ScanStatus.inProgress)
|
||
: before.status;
|
||
|
||
final optimisticItem = beforeItem.copyWith(
|
||
scanProgress: ScanProgress(
|
||
status: newStatus,
|
||
scannedQuantity: before.scannedQuantity,
|
||
creditedQuantity: newCredited,
|
||
lastUpdatedAt: DateTime.now(),
|
||
),
|
||
);
|
||
emit(current.copyWith(
|
||
details: _replaceItem(current.details, optimisticItem),
|
||
));
|
||
|
||
final intent = ScanIntent(
|
||
clientScanId: _uuid.v4(),
|
||
clientScannedAt: DateTime.now(),
|
||
deliveryItemId: event.deliveryItemId,
|
||
action: ScanAction.unremove,
|
||
actorCarId: event.actorCarId,
|
||
quantity: event.quantity,
|
||
);
|
||
final applied = await _applyAndReconcile(emit, intent, beforeItem);
|
||
|
||
// Spiegelbild zur Notiz-Erzeugung beim Entfernen: nach erfolgreicher
|
||
// Rücknahme die zugehörigen Gutschrift-Notizen (mit Bezug auf genau
|
||
// diese Belegzeile) wieder löschen. Aus der aktuellsten Notizliste
|
||
// gelesen, damit auch nach einem Tour-Reload die richtigen IDs erwischt
|
||
// werden.
|
||
if (applied) {
|
||
final latest = state;
|
||
if (latest is TourLoaded) {
|
||
final linked = latest.details
|
||
.notesOf(beforeItem.deliveryId)
|
||
.where((note) => note.creditDeliveryItemId == event.deliveryItemId)
|
||
.toList(growable: false);
|
||
for (final note in linked) {
|
||
add(DeleteDeliveryNote(
|
||
deliveryId: beforeItem.deliveryId,
|
||
noteId: note.id,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Betrags-Gutschrift ──────────────────────────────────────────────
|
||
|
||
/// Tauscht die Gutschrift einer Lieferung im Aggregat aus (`null` = entfernt).
|
||
TourDetails _withCredit(
|
||
TourDetails details,
|
||
String deliveryId,
|
||
DeliveryCredit? credit,
|
||
) {
|
||
final next = Map<String, DeliveryCredit>.from(details.creditsByDeliveryId);
|
||
if (credit == null) {
|
||
next.remove(deliveryId);
|
||
} else {
|
||
next[deliveryId] = credit;
|
||
}
|
||
return details.copyWith(creditsByDeliveryId: next);
|
||
}
|
||
|
||
/// Löscht alle Betrags-Gutschrift-Notizen einer Lieferung am Backend
|
||
/// (best-effort — die Notiz ist Beiwerk, die Gutschrift selbst ist führend).
|
||
Future<void> _deleteAmountCreditNotes(
|
||
String deliveryId,
|
||
Iterable<DeliveryNote> notes,
|
||
) async {
|
||
for (final note in notes.where((n) => n.isAmountCreditNote)) {
|
||
try {
|
||
await tourRepository.deleteDeliveryNote(
|
||
deliveryId: deliveryId,
|
||
noteId: note.id,
|
||
);
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc: Betrags-Gutschrift-Notiz löschen fehlgeschlagen: $e\n$st');
|
||
}
|
||
}
|
||
}
|
||
|
||
/// State-Update: entfernt alle Betrags-Gutschrift-Notizen der Lieferung aus
|
||
/// der Notiz-Map und hängt optional [newNote] an. So bleibt pro Lieferung
|
||
/// genau eine aktuelle Grund-Notiz.
|
||
TourDetails _replaceAmountCreditNotes(
|
||
TourDetails details,
|
||
String deliveryId,
|
||
DeliveryNote? newNote,
|
||
) {
|
||
final nextNotes = Map<String, List<DeliveryNote>>.from(
|
||
details.notesByDeliveryId,
|
||
);
|
||
final existing = nextNotes[deliveryId] ?? const <DeliveryNote>[];
|
||
final kept = existing.where((n) => !n.isAmountCreditNote).toList();
|
||
if (newNote != null) kept.add(newNote);
|
||
nextNotes[deliveryId] = kept;
|
||
return details.copyWith(notesByDeliveryId: nextNotes);
|
||
}
|
||
|
||
Future<void> _onSetDeliveryCredit(
|
||
SetDeliveryCredit event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
final previous = current.details.creditOf(event.deliveryId);
|
||
// Bestehende Grund-Notizen merken (zum Ersetzen nach Erfolg).
|
||
final existingNotes = current.details.notesOf(event.deliveryId);
|
||
// Optimistisch sofort anzeigen.
|
||
emit(current.copyWith(
|
||
details: _withCredit(
|
||
current.details,
|
||
event.deliveryId,
|
||
DeliveryCredit(
|
||
deliveryId: event.deliveryId,
|
||
amountCents: event.amountCents,
|
||
reason: event.reason,
|
||
),
|
||
),
|
||
));
|
||
|
||
// Sichtbares „Request läuft"-Feedback (blockierender Spinner) + Erfolgs-/
|
||
// Fehler-SnackBar über den OperationBloc.
|
||
opBloc.add(StartOperation(message: 'Gutschrift wird gespeichert …'));
|
||
try {
|
||
final result = await tourRepository.setDeliveryCredit(
|
||
deliveryId: event.deliveryId,
|
||
clientEventId: _uuid.v4(),
|
||
amountCents: event.amountCents,
|
||
reason: event.reason,
|
||
actorCarId: event.actorCarId,
|
||
);
|
||
|
||
// Grund als Notiz festhalten (wie beim Artikel-Entfernen). Alte
|
||
// Betrags-Gutschrift-Notizen ersetzen, damit pro Lieferung genau eine
|
||
// aktuelle Grund-Notiz steht. Best-effort — schlägt es fehl, bleibt die
|
||
// Gutschrift trotzdem gesetzt.
|
||
DeliveryNote? newNote;
|
||
try {
|
||
await _deleteAmountCreditNotes(event.deliveryId, existingNotes);
|
||
newNote = await tourRepository.addDeliveryNote(
|
||
deliveryId: event.deliveryId,
|
||
text: 'Betrags-Gutschrift – ${event.amountCents ~/ 100} €: ${event.reason}',
|
||
isAmountCreditNote: true,
|
||
);
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc: Betrags-Gutschrift-Notiz anlegen fehlgeschlagen: $e\n$st');
|
||
}
|
||
|
||
// Server ist autoritativ — den zurückgelieferten Stand übernehmen.
|
||
final latest = state;
|
||
if (latest is TourLoaded) {
|
||
var details = _withCredit(latest.details, event.deliveryId, result);
|
||
details =
|
||
_replaceAmountCreditNotes(details, event.deliveryId, newNote);
|
||
emit(latest.copyWith(details: details));
|
||
}
|
||
opBloc.add(FinishOperation(message: 'Gutschrift gespeichert'));
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc.SetDeliveryCredit fehlgeschlagen: $e\n$st');
|
||
final latest = state;
|
||
if (latest is TourLoaded) {
|
||
emit(latest.copyWith(
|
||
details: _withCredit(latest.details, event.deliveryId, previous),
|
||
));
|
||
}
|
||
opBloc.add(FailOperation(
|
||
message: _messageOf(e, 'Gutschrift konnte nicht gespeichert werden'),
|
||
));
|
||
}
|
||
}
|
||
|
||
Future<void> _onRemoveDeliveryCredit(
|
||
RemoveDeliveryCredit event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
final previous = current.details.creditOf(event.deliveryId);
|
||
if (previous == null) return; // nichts zu entfernen
|
||
|
||
final existingNotes = current.details.notesOf(event.deliveryId);
|
||
|
||
emit(current.copyWith(
|
||
details: _withCredit(current.details, event.deliveryId, null),
|
||
));
|
||
|
||
opBloc.add(StartOperation(message: 'Gutschrift wird entfernt …'));
|
||
try {
|
||
await tourRepository.removeDeliveryCredit(
|
||
deliveryId: event.deliveryId,
|
||
clientEventId: _uuid.v4(),
|
||
actorCarId: event.actorCarId,
|
||
);
|
||
|
||
// Spiegelbild zum Setzen: zugehörige Grund-Notiz(en) wieder löschen.
|
||
await _deleteAmountCreditNotes(event.deliveryId, existingNotes);
|
||
final latest = state;
|
||
if (latest is TourLoaded) {
|
||
emit(latest.copyWith(
|
||
details: _replaceAmountCreditNotes(
|
||
latest.details,
|
||
event.deliveryId,
|
||
null,
|
||
),
|
||
));
|
||
}
|
||
opBloc.add(FinishOperation(message: 'Gutschrift entfernt'));
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc.RemoveDeliveryCredit fehlgeschlagen: $e\n$st');
|
||
final latest = state;
|
||
if (latest is TourLoaded) {
|
||
emit(latest.copyWith(
|
||
details: _withCredit(latest.details, event.deliveryId, previous),
|
||
));
|
||
}
|
||
opBloc.add(FailOperation(
|
||
message: _messageOf(e, 'Gutschrift konnte nicht entfernt werden'),
|
||
));
|
||
}
|
||
}
|
||
|
||
// ─── Services (Phase 4) ──────────────────────────────────────────────
|
||
|
||
/// Tauscht den Service-Wert einer Lieferung im Aggregat aus
|
||
/// (`null` = entfernt).
|
||
TourDetails _withServiceValue(
|
||
TourDetails details,
|
||
String deliveryId,
|
||
String serviceId,
|
||
DeliveryServiceValue? value,
|
||
) {
|
||
final outer = Map<String, Map<String, DeliveryServiceValue>>.from(
|
||
details.serviceValuesByDeliveryId,
|
||
);
|
||
final inner = Map<String, DeliveryServiceValue>.from(
|
||
outer[deliveryId] ?? const <String, DeliveryServiceValue>{},
|
||
);
|
||
if (value == null) {
|
||
inner.remove(serviceId);
|
||
} else {
|
||
inner[serviceId] = value;
|
||
}
|
||
outer[deliveryId] = inner;
|
||
return details.copyWith(serviceValuesByDeliveryId: outer);
|
||
}
|
||
|
||
Future<void> _onSetDeliveryServiceValue(
|
||
SetDeliveryServiceValue event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
final previous =
|
||
current.details.serviceValueOf(event.deliveryId, event.serviceId);
|
||
emit(current.copyWith(
|
||
details: _withServiceValue(
|
||
current.details,
|
||
event.deliveryId,
|
||
event.serviceId,
|
||
DeliveryServiceValue(
|
||
deliveryId: event.deliveryId,
|
||
serviceId: event.serviceId,
|
||
boolValue: event.boolValue,
|
||
numericValue: event.numericValue,
|
||
),
|
||
),
|
||
));
|
||
|
||
opBloc.add(StartOperation(message: 'Service wird gespeichert …'));
|
||
try {
|
||
final result = await tourRepository.setDeliveryService(
|
||
deliveryId: event.deliveryId,
|
||
serviceId: event.serviceId,
|
||
boolValue: event.boolValue,
|
||
numericValue: event.numericValue,
|
||
actorCarId: event.actorCarId,
|
||
);
|
||
final latest = state;
|
||
if (latest is TourLoaded) {
|
||
emit(latest.copyWith(
|
||
details: _withServiceValue(
|
||
latest.details,
|
||
event.deliveryId,
|
||
event.serviceId,
|
||
result,
|
||
),
|
||
));
|
||
}
|
||
opBloc.add(FinishOperation(message: 'Service gespeichert'));
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc.SetDeliveryServiceValue fehlgeschlagen: $e\n$st');
|
||
final latest = state;
|
||
if (latest is TourLoaded) {
|
||
emit(latest.copyWith(
|
||
details: _withServiceValue(
|
||
latest.details,
|
||
event.deliveryId,
|
||
event.serviceId,
|
||
previous,
|
||
),
|
||
));
|
||
}
|
||
opBloc.add(FailOperation(
|
||
message: _messageOf(e, 'Service konnte nicht gespeichert werden'),
|
||
));
|
||
}
|
||
}
|
||
|
||
Future<void> _onRemoveDeliveryServiceValue(
|
||
RemoveDeliveryServiceValue event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
final previous =
|
||
current.details.serviceValueOf(event.deliveryId, event.serviceId);
|
||
if (previous == null) return;
|
||
|
||
emit(current.copyWith(
|
||
details: _withServiceValue(
|
||
current.details,
|
||
event.deliveryId,
|
||
event.serviceId,
|
||
null,
|
||
),
|
||
));
|
||
|
||
opBloc.add(StartOperation(message: 'Service wird entfernt …'));
|
||
try {
|
||
await tourRepository.removeDeliveryService(
|
||
deliveryId: event.deliveryId,
|
||
serviceId: event.serviceId,
|
||
);
|
||
opBloc.add(FinishOperation(message: 'Service entfernt'));
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc.RemoveDeliveryServiceValue fehlgeschlagen: $e\n$st');
|
||
final latest = state;
|
||
if (latest is TourLoaded) {
|
||
emit(latest.copyWith(
|
||
details: _withServiceValue(
|
||
latest.details,
|
||
event.deliveryId,
|
||
event.serviceId,
|
||
previous,
|
||
),
|
||
));
|
||
}
|
||
opBloc.add(FailOperation(
|
||
message: _messageOf(e, 'Service konnte nicht entfernt werden'),
|
||
));
|
||
}
|
||
}
|
||
|
||
/// Liefert `true`, wenn der Scan serverseitig angewendet (oder als
|
||
/// `duplicate` akzeptiert) wurde, `false` bei Ablehnung/Fehler. Aufrufer,
|
||
/// die an den Erfolg eine Folgeaktion knüpfen (z. B. die Gutschrift-Notiz),
|
||
/// werten den Rückgabewert aus; die übrigen ignorieren ihn.
|
||
Future<bool> _applyAndReconcile(
|
||
Emitter<TourState> emit,
|
||
ScanIntent intent,
|
||
DeliveryItem before,
|
||
) async {
|
||
try {
|
||
final outcomes = await tourRepository.applyScans([intent]);
|
||
final outcome = outcomes[intent.clientScanId];
|
||
if (outcome == null) {
|
||
// Server hat keinen Eintrag für unseren Scan zurückgegeben —
|
||
// defensiv als rejected behandeln.
|
||
_rollback(emit, before, 'Unklare Server-Antwort');
|
||
return false;
|
||
}
|
||
switch (outcome.status) {
|
||
case ScanOutcomeStatus.applied:
|
||
case ScanOutcomeStatus.duplicate:
|
||
// Erfolg. Optimistisches Update bleibt stehen. (Bei `applied`
|
||
// könnten wir das Server-`newScanState` zurückmischen; das
|
||
// sparen wir uns, weil unsere lokale Inkrement-Logik exakt
|
||
// dem Backend-Verhalten entspricht.)
|
||
return true;
|
||
case ScanOutcomeStatus.rejected:
|
||
_rollback(emit, before, outcome.reason ?? 'Scan abgelehnt');
|
||
return false;
|
||
}
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc.applyScans fehlgeschlagen: $e\n$st');
|
||
_rollback(emit, before, _messageOf(e, 'Scan fehlgeschlagen'));
|
||
return false;
|
||
}
|
||
}
|
||
|
||
void _rollback(Emitter<TourState> emit, DeliveryItem before, String reason) {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
emit(current.copyWith(
|
||
details: _replaceItem(current.details, before),
|
||
));
|
||
opBloc.add(FailOperation(message: reason));
|
||
}
|
||
|
||
// Lookups/Mutationen am Aggregat ---------------------------------------
|
||
|
||
DeliveryItem? _findItem(TourDetails details, String itemId) {
|
||
for (final d in details.deliveries) {
|
||
for (final it in d.items) {
|
||
if (it.id == itemId) return it;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
TourDetails _replaceItem(TourDetails details, DeliveryItem updated) {
|
||
final nextDeliveries = <Delivery>[];
|
||
for (final d in details.deliveries) {
|
||
if (d.id != updated.deliveryId) {
|
||
nextDeliveries.add(d);
|
||
continue;
|
||
}
|
||
final nextItems = <DeliveryItem>[];
|
||
for (final it in d.items) {
|
||
nextItems.add(it.id == updated.id ? updated : it);
|
||
}
|
||
nextDeliveries.add(d.copyWith(items: nextItems));
|
||
}
|
||
return details.copyWith(deliveries: nextDeliveries);
|
||
}
|
||
|
||
// ─── Delivery-Lifecycle ──────────────────────────────────────────────
|
||
|
||
Future<void> _onCancelDelivery(
|
||
CancelDelivery event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
await _runLifecycle(
|
||
emit: emit,
|
||
deliveryId: event.deliveryId,
|
||
operationLabel: 'Lieferung abbrechen',
|
||
apply: () => tourRepository.cancelDelivery(
|
||
deliveryId: event.deliveryId,
|
||
reason: event.reason,
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _onHoldDelivery(
|
||
HoldDelivery event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
await _runLifecycle(
|
||
emit: emit,
|
||
deliveryId: event.deliveryId,
|
||
operationLabel: 'Lieferung pausieren',
|
||
apply: () => tourRepository.holdDelivery(
|
||
deliveryId: event.deliveryId,
|
||
reason: event.reason,
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _onResumeDelivery(
|
||
ResumeDelivery event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
await _runLifecycle(
|
||
emit: emit,
|
||
deliveryId: event.deliveryId,
|
||
operationLabel: 'Lieferung fortsetzen',
|
||
apply: () =>
|
||
tourRepository.resumeDelivery(deliveryId: event.deliveryId),
|
||
);
|
||
}
|
||
|
||
Future<void> _onCompleteDelivery(
|
||
CompleteDelivery event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
await _runLifecycle(
|
||
emit: emit,
|
||
deliveryId: event.deliveryId,
|
||
operationLabel: 'Lieferung abschließen',
|
||
apply: () => tourRepository.completeDelivery(
|
||
deliveryId: event.deliveryId,
|
||
customerSignaturePng: event.customerSignaturePng,
|
||
driverSignaturePng: event.driverSignaturePng,
|
||
receiptConfirmed: event.receiptConfirmed,
|
||
notesAcknowledged: event.notesAcknowledged,
|
||
acknowledgedNoteIds: event.acknowledgedNoteIds,
|
||
paymentMethodId: event.paymentMethodId,
|
||
actorCarId: event.actorCarId,
|
||
paymentCollected: event.paymentCollected,
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Gemeinsamer Pfad für die drei Lifecycle-Operationen: globaler
|
||
/// Loading-Overlay an, Server-Call, lokales Aggregat mit der neuen
|
||
/// Stamm-Delivery mergen (Items/sortOrder aus dem aktuellen State
|
||
/// behalten), Overlay aus. Konservativ — kein optimistisches Update,
|
||
/// weil das selten genug passiert und der Fahrer eh bewusst auf den
|
||
/// Reason-Dialog reagiert.
|
||
Future<void> _runLifecycle({
|
||
required Emitter<TourState> emit,
|
||
required String deliveryId,
|
||
required String operationLabel,
|
||
required Future<Delivery> Function() apply,
|
||
}) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
opBloc.add(StartOperation());
|
||
try {
|
||
final remote = await apply();
|
||
final local = current.details.deliveries
|
||
.firstWhere((d) => d.id == deliveryId, orElse: () => remote);
|
||
final merged = remote.copyWith(
|
||
sortOrder: local.sortOrder,
|
||
items: local.items,
|
||
);
|
||
emit(current.copyWith(
|
||
details: current.details.replaceDelivery(merged),
|
||
));
|
||
opBloc.add(FinishOperation());
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc.$operationLabel fehlgeschlagen: $e\n$st');
|
||
opBloc.add(FailOperation(
|
||
message: _messageOf(e, '$operationLabel fehlgeschlagen'),
|
||
));
|
||
}
|
||
}
|
||
|
||
// ─── AddDeliveryNote ─────────────────────────────────────────────────
|
||
|
||
/// Schreibt eine Notiz an einer Lieferung und hängt sie in das lokale
|
||
/// Tour-Aggregat ein. Bewusst kein optimistisches Update — die UI zeigt
|
||
/// einen kurzen Loader und bekommt erst danach die Notiz mit echter
|
||
/// Server-Id zurück; das vermeidet Geister-Notizen, wenn der Call kippt.
|
||
Future<void> _onAddDeliveryNote(
|
||
AddDeliveryNote event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
opBloc.add(StartOperation());
|
||
try {
|
||
final note = await tourRepository.addDeliveryNote(
|
||
deliveryId: event.deliveryId,
|
||
text: event.text,
|
||
imageAttachment: event.imageAttachment,
|
||
creditDeliveryItemId: event.creditDeliveryItemId,
|
||
);
|
||
final nextNotes = Map<String, List<DeliveryNote>>.from(
|
||
current.details.notesByDeliveryId,
|
||
);
|
||
final existing = nextNotes[event.deliveryId] ?? const <DeliveryNote>[];
|
||
// Backend liefert die Liste pro Lieferung nach `createdAt` sortiert —
|
||
// wir hängen die neue Notiz hinten an, weil sie als jüngste Notiz
|
||
// automatisch am Ende landet.
|
||
nextNotes[event.deliveryId] = [...existing, note];
|
||
emit(current.copyWith(
|
||
details: current.details.copyWith(notesByDeliveryId: nextNotes),
|
||
));
|
||
opBloc.add(FinishOperation());
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc.AddDeliveryNote fehlgeschlagen: $e\n$st');
|
||
opBloc.add(FailOperation(
|
||
message: _messageOf(e, 'Notiz konnte nicht gespeichert werden'),
|
||
));
|
||
}
|
||
}
|
||
|
||
// ─── UpdateDeliveryNote ──────────────────────────────────────────────
|
||
|
||
Future<void> _onUpdateDeliveryNote(
|
||
UpdateDeliveryNote event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
opBloc.add(StartOperation());
|
||
try {
|
||
final updated = await tourRepository.updateDeliveryNote(
|
||
deliveryId: event.deliveryId,
|
||
noteId: event.noteId,
|
||
text: event.text,
|
||
imageAttachment: event.imageAttachment,
|
||
);
|
||
final nextNotes = Map<String, List<DeliveryNote>>.from(
|
||
current.details.notesByDeliveryId,
|
||
);
|
||
final list = List<DeliveryNote>.of(
|
||
nextNotes[event.deliveryId] ?? const <DeliveryNote>[],
|
||
);
|
||
final idx = list.indexWhere((n) => n.id == event.noteId);
|
||
if (idx != -1) {
|
||
list[idx] = updated;
|
||
}
|
||
nextNotes[event.deliveryId] = list;
|
||
emit(current.copyWith(
|
||
details: current.details.copyWith(notesByDeliveryId: nextNotes),
|
||
));
|
||
opBloc.add(FinishOperation());
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc.UpdateDeliveryNote fehlgeschlagen: $e\n$st');
|
||
opBloc.add(FailOperation(
|
||
message: _messageOf(e, 'Notiz konnte nicht geändert werden'),
|
||
));
|
||
}
|
||
}
|
||
|
||
// ─── DeleteDeliveryNote ──────────────────────────────────────────────
|
||
|
||
Future<void> _onDeleteDeliveryNote(
|
||
DeleteDeliveryNote event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
opBloc.add(StartOperation());
|
||
try {
|
||
await tourRepository.deleteDeliveryNote(
|
||
deliveryId: event.deliveryId,
|
||
noteId: event.noteId,
|
||
);
|
||
final nextNotes = Map<String, List<DeliveryNote>>.from(
|
||
current.details.notesByDeliveryId,
|
||
);
|
||
final list = List<DeliveryNote>.of(
|
||
nextNotes[event.deliveryId] ?? const <DeliveryNote>[],
|
||
)..removeWhere((n) => n.id == event.noteId);
|
||
if (list.isEmpty) {
|
||
nextNotes.remove(event.deliveryId);
|
||
} else {
|
||
nextNotes[event.deliveryId] = list;
|
||
}
|
||
emit(current.copyWith(
|
||
details: current.details.copyWith(notesByDeliveryId: nextNotes),
|
||
));
|
||
opBloc.add(FinishOperation());
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc.DeleteDeliveryNote fehlgeschlagen: $e\n$st');
|
||
opBloc.add(FailOperation(
|
||
message: _messageOf(e, 'Notiz konnte nicht gelöscht werden'),
|
||
));
|
||
}
|
||
}
|
||
|
||
// ─── UploadDeliveryNoteImage ─────────────────────────────────────────
|
||
|
||
Future<void> _onUploadDeliveryNoteImage(
|
||
UploadDeliveryNoteImage event,
|
||
Emitter<TourState> emit,
|
||
) async {
|
||
final current = state;
|
||
if (current is! TourLoaded) return;
|
||
|
||
opBloc.add(StartOperation());
|
||
try {
|
||
final note = await tourRepository.uploadDeliveryNoteImage(
|
||
deliveryId: event.deliveryId,
|
||
filename: event.filename,
|
||
mime: event.mime,
|
||
bytes: event.bytes,
|
||
);
|
||
final nextNotes = Map<String, List<DeliveryNote>>.from(
|
||
current.details.notesByDeliveryId,
|
||
);
|
||
final existing = nextNotes[event.deliveryId] ?? const <DeliveryNote>[];
|
||
nextNotes[event.deliveryId] = [...existing, note];
|
||
emit(current.copyWith(
|
||
details: current.details.copyWith(notesByDeliveryId: nextNotes),
|
||
));
|
||
opBloc.add(FinishOperation());
|
||
} catch (e, st) {
|
||
debugPrint('TourBloc.UploadDeliveryNoteImage fehlgeschlagen: $e\n$st');
|
||
opBloc.add(FailOperation(
|
||
message: _messageOf(e, 'Bild konnte nicht hochgeladen werden'),
|
||
));
|
||
}
|
||
}
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||
|
||
String _messageOf(Object e, String fallback) {
|
||
if (e is TourRepositoryException) return e.message;
|
||
return fallback;
|
||
}
|
||
}
|