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 { TourBloc({ required this.tourRepository, required this.opBloc, required this.attachmentCache, }) : super(const TourInitial()) { on(_onLoad); on(_onRefresh); on(_onReorder); on(_onAssignCar); on(_onAssignCarBulk); on(_onScanItem); on(_onUnscanItem); on(_onHoldItem); on(_onUnholdItem); on(_onRemoveItem); on(_onUnremoveItem); on(_onCancelDelivery); on(_onHoldDelivery); on(_onResumeDelivery); on(_onCompleteDelivery); on(_onAddDeliveryNote); on(_onUpdateDeliveryNote); on(_onDeleteDeliveryNote); on(_onUploadDeliveryNoteImage); on(_onSetDeliveryCredit); on(_onRemoveDeliveryCredit); on(_onSetDeliveryServiceValue); on(_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 _onLoad(LoadTour event, Emitter 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 _onRefresh(RefreshTour event, Emitter 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 _onReorder( ReorderDeliveries event, Emitter 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 newOrderByDeliveryId, ) { final next = []; 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 _onAssignCar( AssignCarToDelivery event, Emitter 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 _onAssignCarBulk( AssignCarToDeliveries event, Emitter 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 _onScanItem(ScanItem event, Emitter 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 _onUnscanItem( UnscanItem event, Emitter 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 _onHoldItem(HoldItem event, Emitter 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 _onUnholdItem( UnholdItem event, Emitter 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 _onRemoveItem( RemoveItem event, Emitter 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 _onUnremoveItem( UnremoveItem event, Emitter 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.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 _deleteAmountCreditNotes( String deliveryId, Iterable 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>.from( details.notesByDeliveryId, ); final existing = nextNotes[deliveryId] ?? const []; final kept = existing.where((n) => !n.isAmountCreditNote).toList(); if (newNote != null) kept.add(newNote); nextNotes[deliveryId] = kept; return details.copyWith(notesByDeliveryId: nextNotes); } Future _onSetDeliveryCredit( SetDeliveryCredit event, Emitter 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 _onRemoveDeliveryCredit( RemoveDeliveryCredit event, Emitter 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>.from( details.serviceValuesByDeliveryId, ); final inner = Map.from( outer[deliveryId] ?? const {}, ); if (value == null) { inner.remove(serviceId); } else { inner[serviceId] = value; } outer[deliveryId] = inner; return details.copyWith(serviceValuesByDeliveryId: outer); } Future _onSetDeliveryServiceValue( SetDeliveryServiceValue event, Emitter 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 _onRemoveDeliveryServiceValue( RemoveDeliveryServiceValue event, Emitter 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 _applyAndReconcile( Emitter 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 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 = []; for (final d in details.deliveries) { if (d.id != updated.deliveryId) { nextDeliveries.add(d); continue; } final nextItems = []; 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 _onCancelDelivery( CancelDelivery event, Emitter emit, ) async { await _runLifecycle( emit: emit, deliveryId: event.deliveryId, operationLabel: 'Lieferung abbrechen', apply: () => tourRepository.cancelDelivery( deliveryId: event.deliveryId, reason: event.reason, ), ); } Future _onHoldDelivery( HoldDelivery event, Emitter emit, ) async { await _runLifecycle( emit: emit, deliveryId: event.deliveryId, operationLabel: 'Lieferung pausieren', apply: () => tourRepository.holdDelivery( deliveryId: event.deliveryId, reason: event.reason, ), ); } Future _onResumeDelivery( ResumeDelivery event, Emitter emit, ) async { await _runLifecycle( emit: emit, deliveryId: event.deliveryId, operationLabel: 'Lieferung fortsetzen', apply: () => tourRepository.resumeDelivery(deliveryId: event.deliveryId), ); } Future _onCompleteDelivery( CompleteDelivery event, Emitter 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 _runLifecycle({ required Emitter emit, required String deliveryId, required String operationLabel, required Future 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 _onAddDeliveryNote( AddDeliveryNote event, Emitter 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>.from( current.details.notesByDeliveryId, ); final existing = nextNotes[event.deliveryId] ?? const []; // 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 _onUpdateDeliveryNote( UpdateDeliveryNote event, Emitter 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>.from( current.details.notesByDeliveryId, ); final list = List.of( nextNotes[event.deliveryId] ?? const [], ); 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 _onDeleteDeliveryNote( DeleteDeliveryNote event, Emitter 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>.from( current.details.notesByDeliveryId, ); final list = List.of( nextNotes[event.deliveryId] ?? const [], )..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 _onUploadDeliveryNoteImage( UploadDeliveryNoteImage event, Emitter 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>.from( current.details.notesByDeliveryId, ); final existing = nextNotes[event.deliveryId] ?? const []; 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; } }