Final commit.

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

View File

@ -150,6 +150,41 @@ Smoke: Login → `loadTourOfToday()` → Tour kommt durch.
Smoke: Tab-Navigation funktioniert, jeder Tab lädt ohne Crash. Smoke: Tab-Navigation funktioniert, jeder Tab lädt ohne Crash.
### Phase G — Delivery-Lifecycle-Audit-Log (offen)
**Status:** geplant, noch nicht begonnen. Aus der Diskussion zur
Wiederherstellung abgebrochener Lieferungen (Phase C+D-4): heute
gehen `state_reason`-Begründungen beim `resume` verloren, weil das
Feld direkt an der `deliveries`-Zeile lebt und beim Wiederherstellen
genullt wird. Item-Aktionen sind sauber auditierbar (`scan_audit`,
append-only) — Delivery-Lifecycle ist es nicht.
**Scope:**
1. Neue Tabelle `delivery_audit` analog zu `scan_audit`:
- `id`, `delivery_id`, `client_action_id` (UUID UNIQUE — Idempotenz)
- `action` (`hold`|`resume`|`cancel`|`complete`)
- `previous_state`, `resulting_state`
- `reason` (Pflicht bei `hold` / `cancel`)
- `actor_personalnummer`, `actor_car_id?`
- `client_acted_at`, `server_recorded_at`
- denormalisierter ERP-Bezug: `erp_belegart_id`, `erp_belegnummer`
2. Backend: Audit-Insert in jedem `apply_action`-Pfad
(`delivery_repository.rs`). Request-DTOs bekommen Pflichtfelder
`clientActionId` + `clientActedAt`.
3. OpenAPI + Dart-Client neu generieren.
4. App-Bloc: UUID + Timestamp pro Lifecycle-Event mitsenden
(Helper-Funktion analog zur Scan-Pipeline, dort sitzt das Pattern
schon im `TourBloc`).
5. **Optional, zweiter Schritt:** `GET /deliveries/{id}/audit`
plus UI-Anzeige der Historie. Sinnvollste Stelle: im
„Wiederherstellen"-Dialog vom Cancel-Recovery zeigen wir den
ursprünglichen Eintrag („Wurde am … durch … abgebrochen mit
Grund: …"), damit der Fahrer eine bewusste Entscheidung trifft.
**Out of Scope dieser Phase:**
- `assignCar`-Audit (andere Geste, kein Reason, eigene Sub-Phase).
- Notizen-Audit (Notes sind schon append-only — separate Aktivität).
### Phase F — Smoke des kompletten Flows ### Phase F — Smoke des kompletten Flows
Manuell durchklicken: Manuell durchklicken:
1. Login (Keycloak) 1. Login (Keycloak)

View File

@ -1,43 +1,20 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/bloc/app_events.dart'; import 'package:hl_lieferservice/bloc/app_events.dart';
import 'package:hl_lieferservice/bloc/app_states.dart'; import 'package:hl_lieferservice/bloc/app_states.dart';
import 'package:hl_lieferservice/main.dart';
import 'package:hl_lieferservice/repository/config.dart';
import '../services/erpframe.dart';
/// App-Bootstrap-Bloc.
///
/// Vor der Backend-Migration lud dieser Bloc eine `hl_server_config.json` aus
/// assets, parste daraus eine `backendUrl` und persistierte sie ins Dateisystem.
/// Mit dem Wechsel auf das Rust-Backend kommt die URL über `BackendConfig`
/// (compile-time, siehe `data/network/backend_config.dart`); der App-Bloc
/// emittiert jetzt nur noch sofort `AppConfigLoaded`, damit die UI ihre
/// üblichen Phasen-Übergänge behält.
class AppBloc extends Bloc<AppEvents, AppState> { class AppBloc extends Bloc<AppEvents, AppState> {
AppBloc() : super(AppInitial()) { AppBloc() : super(const AppInitial()) {
on<AppLoadConfig>(_loadConfig); on<AppLoadConfig>((event, emit) {
} emit(const AppConfigLoading());
emit(const AppConfigLoaded());
Future<void> _loadConfig(AppLoadConfig event, Emitter<AppState> emit) async { });
emit(AppConfigLoading());
try {
final repository = ConfigurationRepository(path: event.path);
final configuration = LocalDocuFrameConfiguration.fromJson(
json.decode(await rootBundle.loadString("assets/${event.path}")),
);
repository.setDocuFrameConfiguration(configuration);
var config = await repository.getDocuFrameConfiguration();
locator.registerSingleton<LocalDocuFrameConfiguration>(config);
emit(AppConfigLoaded(config: config));
} catch (e, st) {
debugPrint(e.toString());
debugPrint(st.toString());
emit(
AppConfigLoadingFailed(
message: "Fehler beim Laden der Konfigurationsdatei.",
),
);
}
} }
} }

View File

@ -1,16 +1,27 @@
import '../services/erpframe.dart'; /// Lifecycle-States des App-Bootstraps.
///
/// Die alte `LocalDocuFrameConfiguration` mit `backendUrl` ist mit der
/// Backend-Migration entfallen — die App-Konfiguration kommt jetzt aus
/// `BackendConfig` (compile-time) und nicht mehr aus einer asset-JSON.
/// Das `AppConfigLoaded`-Signal bleibt als Marker, dass der App-Bootstrap
/// abgeschlossen ist (Networking ist registriert, Token-Provider steht).
abstract class AppState {
const AppState();
}
abstract class AppState {} class AppInitial extends AppState {
const AppInitial();
}
class AppConfigLoading extends AppState {
const AppConfigLoading();
}
class AppInitial extends AppState {}
class AppConfigLoading extends AppState {}
class AppConfigLoaded extends AppState { class AppConfigLoaded extends AppState {
LocalDocuFrameConfiguration config; const AppConfigLoaded();
AppConfigLoaded({required this.config});
} }
class AppConfigLoadingFailed extends AppState { class AppConfigLoadingFailed extends AppState {
String message; const AppConfigLoadingFailed({required this.message});
final String message;
AppConfigLoadingFailed({required this.message});
} }

162
lib/data/cache/attachment_cache.dart vendored Normal file
View File

@ -0,0 +1,162 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
/// Persistenter Datei-Cache für heruntergeladene Attachment-Vorschauen.
///
/// **Warum überhaupt ein Cache:** Vorschaubilder werden über
/// `GET /attachments/{id}` aus DOCUframe gerendert — das kostet Zeit und
/// Bandbreite und funktioniert offline gar nicht. Einmal geholte Varianten
/// landen deshalb auf der Platte und werden danach lokal bedient.
///
/// **Warum keine Content-Revalidierung:** Attachments sind unveränderlich.
/// Ein hochgeladenes Bild (DOCUframe-Objekt) ändert seinen Inhalt nie. Eine
/// einmal gecachte Variante ist daher dauerhaft gültig — kein ETag, kein
/// If-None-Match, kein HEAD nötig. Das Einzige, was den Cache betrifft, ist
/// das **Löschen** eines Attachments; dafür gibt es [retainOnly], das den
/// Cache auf die Menge noch gültiger Attachment-IDs eindampft.
///
/// **Datei-Layout:** Pro Attachment können mehrere *Varianten* liegen
/// (Thumbnail 600×600, Vollbild 2048×2048, …). Der Dateiname kodiert ID und
/// Render-Parameter:
///
/// `{attachmentId}__{w}x{h}_q{q}_{ext}`
///
/// Die ID steht vorne und ist als UUID frei von `__`, sodass [retainOnly] sie
/// zuverlässig wieder herausschneiden kann.
///
/// Der Cache ist durchweg **best-effort**: Lese-/Schreib-/Lösch-Fehler werden
/// geschluckt und führen höchstens zu einem erneuten Download, nie zu einem
/// Crash.
class AttachmentCache {
AttachmentCache();
static const _subdir = 'attachment_previews';
static const _separator = '__';
/// Einmal aufgelöstes Verzeichnis — `getApplicationCacheDirectory` nicht
/// bei jedem Zugriff neu abfragen.
Future<Directory>? _dirFuture;
Future<Directory> _dir() => _dirFuture ??= _resolveDir();
Future<Directory> _resolveDir() async {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$_subdir');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
String _fileName({
required String attachmentId,
required int w,
required int h,
required int q,
required String ext,
}) =>
'$attachmentId$_separator${w}x${h}_q${q}_$ext';
Future<File> _file({
required String attachmentId,
required int w,
required int h,
required int q,
required String ext,
}) async {
final dir = await _dir();
return File(
'${dir.path}/${_fileName(attachmentId: attachmentId, w: w, h: h, q: q, ext: ext)}',
);
}
/// Liest eine gecachte Variante. `null`, wenn nichts da ist oder das Lesen
/// scheitert — der Aufrufer lädt dann frisch.
Future<Uint8List?> read({
required String attachmentId,
required int w,
required int h,
required int q,
required String ext,
}) async {
try {
final f = await _file(
attachmentId: attachmentId,
w: w,
h: h,
q: q,
ext: ext,
);
if (!await f.exists()) return null;
final bytes = await f.readAsBytes();
return bytes.isEmpty ? null : bytes;
} catch (_) {
return null;
}
}
/// Schreibt eine Variante. Atomar via temp-Datei + rename, damit ein
/// paralleler Read nie ein halb geschriebenes File sieht. Leere Bytes
/// werden ignoriert (kaputter Download soll keinen leeren Cache-Eintrag
/// hinterlassen).
Future<void> write({
required String attachmentId,
required int w,
required int h,
required int q,
required String ext,
required Uint8List bytes,
}) async {
if (bytes.isEmpty) return;
try {
final f = await _file(
attachmentId: attachmentId,
w: w,
h: h,
q: q,
ext: ext,
);
final tmp = File('${f.path}.tmp');
await tmp.writeAsBytes(bytes, flush: true);
await tmp.rename(f.path);
} catch (_) {
// best-effort
}
}
/// Entfernt alle gecachten Dateien, deren Attachment-ID **nicht** in
/// [validAttachmentIds] vorkommt. So verschwinden die Vorschauen gelöschter
/// Foto-Notizen beim nächsten Tour-Load aus dem Cache. Verwaiste
/// temp-Dateien (abgebrochene Writes) werden immer mit entfernt.
Future<void> retainOnly(Set<String> validAttachmentIds) async {
try {
final dir = await _dir();
if (!await dir.exists()) return;
await for (final entity in dir.list()) {
if (entity is! File) continue;
final name = entity.uri.pathSegments.last;
if (name.endsWith('.tmp')) {
await _deleteQuietly(entity);
continue;
}
final sepIdx = name.indexOf(_separator);
final id = sepIdx == -1 ? name : name.substring(0, sepIdx);
if (!validAttachmentIds.contains(id)) {
await _deleteQuietly(entity);
}
}
} catch (_) {
// best-effort — Pruning darf nie den Tour-Load stören
}
}
Future<void> _deleteQuietly(File f) async {
try {
await f.delete();
} catch (_) {
// ignore
}
}
}

View File

@ -0,0 +1,408 @@
import 'package:holzleitner_api/holzleitner_api.dart' as api;
import 'package:hl_lieferservice/domain/entity/address.dart';
import 'package:hl_lieferservice/domain/entity/article.dart';
import 'package:hl_lieferservice/domain/entity/contact_source.dart';
import 'package:hl_lieferservice/domain/entity/customer.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/service.dart';
import 'package:hl_lieferservice/domain/entity/payment_method.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.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/domain/entity/warehouse.dart';
/// Eine Schicht, ein Mapper-File: alle Übersetzungen vom generierten
/// `built_value`-Client zur Domain. Bewusst pro DTO eine Extension, damit
/// Aufrufer sich nicht in benamten Funktionen verlieren.
// ─── Primitive ────────────────────────────────────────────────────────────
extension ApiAddressMapper on api.Address {
Address toDomain() => Address(
street: street,
houseNumber: houseNumber,
postalCode: postalCode,
city: city,
country: country,
);
}
// ─── Stammdaten ───────────────────────────────────────────────────────────
extension ApiWarehouseMapper on api.Warehouse {
Warehouse toDomain() => Warehouse(
id: id,
name: name,
code: code,
isStandard: isStandard,
);
}
extension ApiArticleMapper on api.Article {
Article toDomain() => Article(
id: id,
articleNumber: articleNumber,
name: name,
scannable: scannable,
defaultWarehouseId: defaultWarehouseId,
);
}
extension ApiCustomerMapper on api.Customer {
Customer toDomain() => Customer(
id: id,
name: name,
erpCustomerId: erpCustomerId,
address: address.toDomain(),
);
}
extension ApiCustomerContactMapper on api.CustomerContact {
CustomerContact toDomain() => CustomerContact(
id: id,
customerId: customerId,
name: name,
phone: phone,
email: email,
);
}
// ─── Beleg-Kontaktquellen (ContactSource / ContactChannel) ───────────────
//
// Der OpenAPI-Generator erzeugt für die snake-case-serde-Enums im Backend
// `EnumClass`-Wrapper mit camelCase-Identifiern. Verglichen wird wie bei
// `ScanStatus` per Identitäts-Check; Fallback ist ein StateError, damit
// neue Backend-Werte sofort auffallen statt schweigend zu mappen.
extension ApiContactRoleMapper on api.ContactRole {
ContactRole toDomain() {
if (this == api.ContactRole.header) return ContactRole.header;
if (this == api.ContactRole.delivery) return ContactRole.delivery;
if (this == api.ContactRole.billing) return ContactRole.billing;
if (this == api.ContactRole.contactPerson) return ContactRole.contactPerson;
if (this == api.ContactRole.customerMaster) {
return ContactRole.customerMaster;
}
throw StateError('Unbekannte ContactRole vom Backend: $this');
}
}
extension ApiContactKindMapper on api.ContactKind {
ContactKind toDomain() {
if (this == api.ContactKind.phone) return ContactKind.phone;
if (this == api.ContactKind.mobile) return ContactKind.mobile;
if (this == api.ContactKind.email) return ContactKind.email;
if (this == api.ContactKind.web) return ContactKind.web;
throw StateError('Unbekannter ContactKind vom Backend: $this');
}
}
extension ApiContactSourceMapper on api.ContactSource {
ContactSource toDomain() => ContactSource(
id: id,
deliveryId: deliveryId,
role: role.toDomain(),
anrede: anrede,
titel: titel,
name1: name1,
name2: name2,
name3: name3,
abteilung: abteilung,
funktion: funktion,
);
}
extension ApiContactChannelMapper on api.ContactChannel {
ContactChannel toDomain() => ContactChannel(
id: id,
sourceId: sourceId,
kind: kind.toDomain(),
position: position,
value: value,
);
}
// ─── Scan-Progress & Delivery-Item ───────────────────────────────────────
extension ApiScanStateMapper on api.ScanState {
ScanProgress toDomain() => ScanProgress(
status: status.toDomain(),
scannedQuantity: scannedQuantity,
creditedQuantity: creditedQuantity,
lastUpdatedAt: lastUpdatedAt,
heldReason: heldReason,
);
}
// ─── Scan-Apply ──────────────────────────────────────────────────────────
extension DomainScanActionMapper on ScanAction {
api.AuditAction toWire() {
switch (this) {
case ScanAction.scan:
return api.AuditAction.scan;
case ScanAction.unscan:
return api.AuditAction.unscan;
case ScanAction.hold:
return api.AuditAction.hold;
case ScanAction.unhold:
return api.AuditAction.unhold;
case ScanAction.remove:
return api.AuditAction.remove;
case ScanAction.unremove:
return api.AuditAction.unremove;
}
}
}
extension DomainScanIntentMapper on ScanIntent {
api.ScanEvent toWire() => api.ScanEvent((b) {
b
..clientScanId = clientScanId
..clientScannedAt = clientScannedAt.toUtc()
..deliveryItemId = deliveryItemId
..action = action.toWire()
..actorCarId = actorCarId
..reason = reason
// Nur für remove/unremove relevant; null = ganze Restmenge.
..quantity = quantity
..manual = manual;
});
}
extension ApiScanResultStatusMapper on api.ScanResultStatus {
ScanOutcomeStatus toDomain() {
if (this == api.ScanResultStatus.applied) return ScanOutcomeStatus.applied;
if (this == api.ScanResultStatus.duplicate) {
return ScanOutcomeStatus.duplicate;
}
if (this == api.ScanResultStatus.rejected) {
return ScanOutcomeStatus.rejected;
}
throw StateError('Unbekannter ScanResultStatus vom Backend: $this');
}
}
extension ApiScanResultMapper on api.ScanResult {
ScanOutcome toDomain() => ScanOutcome(
clientScanId: clientScanId,
status: status.toDomain(),
deliveryItemId: deliveryItemId,
reason: reason,
);
}
extension ApiScanStatusMapper on api.ScanStatus {
ScanStatus toDomain() {
// EnumClass kennt keinen `switch`-Exhaustiveness-Check; deshalb explizit.
if (this == api.ScanStatus.inProgress) return ScanStatus.inProgress;
if (this == api.ScanStatus.done) return ScanStatus.done;
if (this == api.ScanStatus.held) return ScanStatus.held;
if (this == api.ScanStatus.removed) return ScanStatus.removed;
throw StateError('Unbekannter ScanStatus vom Backend: $this');
}
}
extension ApiDeliveryItemMapper on api.DeliveryItem {
DeliveryItem toDomain() => DeliveryItem(
id: id,
deliveryId: deliveryId,
articleId: articleId,
warehouseId: warehouseId,
belegzeilenNr: belegzeilenNr,
requiredQuantity: requiredQuantity,
scanProgress: scanState.toDomain(),
unitPrice: unitPrice,
komponentenArtikelNr: komponentenArtikelNr,
parentArtikelNr: parentArtikelNr,
);
}
// ─── Delivery ────────────────────────────────────────────────────────────
extension ApiDeliveryStateMapper on api.DeliveryState {
DeliveryState toDomain() {
if (this == api.DeliveryState.active) return DeliveryState.active;
if (this == api.DeliveryState.held) return DeliveryState.held;
if (this == api.DeliveryState.canceled) return DeliveryState.canceled;
if (this == api.DeliveryState.completed) return DeliveryState.completed;
throw StateError('Unbekannter DeliveryState vom Backend: $this');
}
}
extension ApiDeliveryWithItemsMapper on api.DeliveryWithItems {
Delivery toDomain() => Delivery(
id: id,
tourId: tourId,
customerId: customerId,
contactPersonIds: contactPersonIds.toList(growable: false),
deliveryAddressSnapshot: deliveryAddressSnapshot.toDomain(),
erpBelegartId: erpBelegartId,
erpBelegnummer: erpBelegnummer,
state: state.toDomain(),
stateReason: stateReason,
sortOrder: sortOrder,
assignedCarId: assignedCarId,
desiredTime: desiredTime,
specialAgreements: specialAgreements,
items: items.map((it) => it.toDomain()).toList(growable: false),
prepaidAmount: prepaidAmount,
paymentMethodId: paymentMethodId,
);
}
extension ApiPaymentMethodMapper on api.PaymentMethod {
PaymentMethod toDomain() => PaymentMethod(
id: id,
code: code,
name: name,
active: active,
createdAt: createdAt,
);
}
// ─── Tour-Notiz ──────────────────────────────────────────────────────────
extension ApiDeliveryNoteMapper on api.DeliveryNote {
DeliveryNote toDomain() => DeliveryNote(
id: id,
deliveryId: deliveryId,
text: text,
imageAttachment: imageAttachment,
authorPersonalnummer: authorPersonalnummer,
authorCarId: authorCarId,
creditDeliveryItemId: creditDeliveryItemId,
isAmountCreditNote: isAmountCreditNote,
imageAttachmentDeleted: imageAttachmentDeleted ?? false,
createdAt: createdAt,
);
}
// ─── Tour-Wurzel ─────────────────────────────────────────────────────────
extension ApiTourMapper on api.Tour {
Tour toDomain() => Tour(
id: id,
accountId: accountId,
date: date.toDateTime(),
syncedAt: syncedAt,
);
}
extension ApiTourSummaryMapper on api.TourSummary {
TourSummary toDomain() => TourSummary(
tourId: tourId,
tourDate: tourDate.toDateTime(),
deliveryCount: deliveryCount,
);
}
extension ApiTourDetailsMapper on api.TourDetails {
TourDetails toDomain() {
final customersMap = <String, Customer>{
for (final c in customers) c.id: c.toDomain(),
};
final contactsMap = <String, CustomerContact>{
for (final c in customerContacts) c.id: c.toDomain(),
};
final articlesMap = <String, Article>{
for (final a in articles) a.id: a.toDomain(),
};
final warehousesMap = <String, Warehouse>{
for (final w in warehouses) w.id: w.toDomain(),
};
// Notizen sind im Wire flach — pro Lieferung indizieren und aufsteigend
// nach createdAt sortieren, damit das UI sich nicht jedes Mal selbst
// sortieren muss.
final notesGrouped = <String, List<DeliveryNote>>{};
for (final n in notes) {
final domain = n.toDomain();
(notesGrouped[domain.deliveryId] ??= <DeliveryNote>[]).add(domain);
}
for (final list in notesGrouped.values) {
list.sort((a, b) => a.createdAt.compareTo(b.createdAt));
}
// Gutschriften: höchstens eine pro Lieferung (aktueller Stand).
final creditsMap = <String, DeliveryCredit>{
for (final c in credits) c.deliveryId: c.toDomain(),
};
// Service-Definitionen (aktiv, sortiert) + Pro-Lieferung-Werte indizieren.
final servicesList =
services.map((s) => s.toDomain()).toList(growable: false);
final serviceValues = <String, Map<String, DeliveryServiceValue>>{};
for (final v in deliveryServices) {
(serviceValues[v.deliveryId] ??= <String, DeliveryServiceValue>{})[
v.serviceId] = v.toDomain();
}
// Kontaktquellen pro Lieferung gruppieren; Kanäle pro Quelle gruppieren.
// Backend liefert sie sortiert (Quellen nach Rolle, Kanäle nach kind +
// position) — wir behalten die Reihenfolge bei.
final sourcesGrouped = <String, List<ContactSource>>{};
for (final s in contactSources) {
final domain = s.toDomain();
(sourcesGrouped[domain.deliveryId] ??= <ContactSource>[]).add(domain);
}
final channelsGrouped = <String, List<ContactChannel>>{};
for (final c in contactChannels) {
final domain = c.toDomain();
(channelsGrouped[domain.sourceId] ??= <ContactChannel>[]).add(domain);
}
return TourDetails(
tour: tour.toDomain(),
deliveries: deliveries.map((d) => d.toDomain()).toList(growable: false),
customers: customersMap,
contacts: contactsMap,
articles: articlesMap,
warehouses: warehousesMap,
notesByDeliveryId: notesGrouped,
creditsByDeliveryId: creditsMap,
services: servicesList,
serviceValuesByDeliveryId: serviceValues,
contactSourcesByDeliveryId: sourcesGrouped,
contactChannelsBySourceId: channelsGrouped,
);
}
}
extension ApiServiceMapper on api.Service {
Service toDomain() => Service(
id: id,
key: key,
name: name,
kind: kind == api.ServiceKind.numeric
? ServiceKind.numeric
: ServiceKind.boolean,
active: active,
sortOrder: sortOrder,
minValue: minValue,
maxValue: maxValue,
);
}
extension ApiDeliveryServiceValueMapper on api.DeliveryServiceValue {
DeliveryServiceValue toDomain() => DeliveryServiceValue(
deliveryId: deliveryId,
serviceId: serviceId,
boolValue: boolValue,
numericValue: numericValue,
);
}
extension ApiDeliveryCreditMapper on api.DeliveryCredit {
DeliveryCredit toDomain() => DeliveryCredit(
deliveryId: deliveryId,
amountCents: amountCents,
reason: reason,
);
}

View File

@ -53,4 +53,44 @@ class BackendConfig {
keycloakClientId: 'holzleitner-app', keycloakClientId: 'holzleitner-app',
keycloakRedirectUrl: 'holzleitner://oauth2redirect', keycloakRedirectUrl: 'holzleitner://oauth2redirect',
); );
/// Konfiguration für USB-Tunnel via `adb reverse` — gedacht für Tests in
/// fremden Netzwerken, in denen das Gerät den Mac nicht über eine LAN-IP
/// erreicht. Alles zeigt auf `localhost`; der Traffic wird über den
/// USB-Bus zum Host getunnelt.
///
/// **Setup vor dem Start (Gerät per USB angesteckt):**
/// ```
/// adb reverse tcp:3000 tcp:3000 # Rust-API
/// adb reverse tcp:8080 tcp:8080 # Keycloak
/// ```
///
/// **Backend-Voraussetzungen**, damit das OIDC-Login funktioniert:
/// * Backend-Env `KEYCLOAK_ISSUER_URL=http://localhost:8080/realms/holzleitner`
/// (muss exakt mit [keycloakIssuerUrl] matchen, sonst 401 `invalid issuer`).
/// * Keycloak muss den Issuer als `localhost` ausgeben — z. B. via
/// `KC_HOSTNAME_URL=http://localhost:8080` (oder Frontend-URL im Realm),
/// sonst prägt es den Container-Hostnamen ins `iss`-Claim.
/// * Der `holzleitner://oauth2redirect`-Redirect bleibt unverändert (das
/// Custom-Scheme ist netzwerk-unabhängig).
///
/// Aktivieren ohne Code-Edit:
/// ```
/// flutter run --dart-define=HL_BACKEND=usb
/// ```
static const BackendConfig usbReverse = BackendConfig(
apiBaseUrl: 'http://localhost:3000',
keycloakIssuerUrl: 'http://localhost:8080/realms/holzleitner',
keycloakClientId: 'holzleitner-app',
keycloakRedirectUrl: 'holzleitner://oauth2redirect',
);
/// Wählt die Config anhand des Compile-Time-Flags `HL_BACKEND`:
/// * `usb` → [usbReverse] (adb-reverse-Tunnel über localhost)
/// * sonst → [localDev] (LAN-IP, Default)
///
/// So muss für einen Netzwerkwechsel nur das Build-Flag gesetzt werden,
/// nicht der Quellcode angefasst.
static const BackendConfig fromEnvironment =
String.fromEnvironment('HL_BACKEND') == 'usb' ? usbReverse : localDev;
} }

View File

@ -55,6 +55,13 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
String? _refreshToken; String? _refreshToken;
Map<String, dynamic>? _idTokenClaims; Map<String, dynamic>? _idTokenClaims;
/// Single-flight-Guard: hält den gerade laufenden Refresh, damit mehrere
/// gleichzeitige Aufrufer (Bootstrap: Restore + PaymentMethodsCubit +
/// Folge-Requests) sich EINEN Refresh teilen statt parallele
/// `flutter_appauth.token()`-Calls auszulösen (die nativ blockieren/haken
/// können → App hängt nach Hot-Restart am Splash/Login).
Future<String?>? _refreshInFlight;
final StreamController<AuthSessionEvent> _events = final StreamController<AuthSessionEvent> _events =
StreamController<AuthSessionEvent>.broadcast(); StreamController<AuthSessionEvent>.broadcast();
@ -166,6 +173,19 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
final rt = _refreshToken; final rt = _refreshToken;
if (rt == null) return null; if (rt == null) return null;
// Single-flight: läuft bereits ein Refresh, hängen wir uns dran, statt
// einen zweiten `flutter_appauth.token()`-Call zu starten. `??=`
// evaluiert die rechte Seite nur, wenn noch kein Refresh läuft.
return _refreshInFlight ??= _performRefresh(rt).whenComplete(() {
_refreshInFlight = null;
});
}
/// Führt EINEN Token-Refresh aus. Bei Erfolg werden die Tokens übernommen
/// und der neue Access-Token zurückgegeben (ohne Event — stiller Refresh).
/// Bei Fehler ist die Session tot: lokal aufräumen, `AuthSessionExpired`
/// emittieren, `null` zurück.
Future<String?> _performRefresh(String rt) async {
try { try {
final result = await _appAuth.token( final result = await _appAuth.token(
TokenRequest( TokenRequest(
@ -187,11 +207,17 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
return _accessToken; return _accessToken;
} on Exception { } on Exception {
// Refresh hat nicht funktioniert — Session ist tot, nicht // Refresh hat nicht funktioniert — Session ist tot, nicht
// wiederherstellbar. Aufrufer kriegen null zurück, AuthBloc // wiederherstellbar. Reihenfolge bewusst: erst State leeren + Event
// bekommt SessionExpired. // feuern, DANN best-effort den Storage löschen — so kann ein
// werfendes `delete` weder das Event verschlucken noch eine Exception
// aus `currentAccessToken()` leaken.
_clearSession(); _clearSession();
await _storage.delete(key: _refreshTokenStorageKey);
_events.add(const AuthSessionExpired()); _events.add(const AuthSessionExpired());
try {
await _storage.delete(key: _refreshTokenStorageKey);
} catch (e) {
debugPrint('currentAccessToken: Refresh-Token-Delete fehlgeschlagen: $e');
}
return null; return null;
} }
} }

View File

@ -17,7 +17,7 @@ import 'keycloak_oidc_token_provider.dart';
/// das reine dart-Smoke-Tool, siehe `tool/smoke_test_api.dart`). /// das reine dart-Smoke-Tool, siehe `tool/smoke_test_api.dart`).
void registerNetworking({ void registerNetworking({
required GetIt locator, required GetIt locator,
BackendConfig config = BackendConfig.localDev, BackendConfig config = BackendConfig.fromEnvironment,
}) { }) {
locator.registerSingleton<BackendConfig>(config); locator.registerSingleton<BackendConfig>(config);

View File

@ -0,0 +1,125 @@
import 'package:dio/dio.dart';
import 'package:holzleitner_api/holzleitner_api.dart' as api;
import 'package:hl_lieferservice/data/mapper/tour_mapper.dart';
import 'package:hl_lieferservice/domain/entity/payment_method.dart';
import 'package:hl_lieferservice/domain/repository/payment_methods_repository.dart';
/// Dio-Impl gegen den generierten `PaymentMethodsApi`.
///
/// Fehler-Mapping:
/// * `409 Conflict` (UNIQUE-Verletzung oder FK-RESTRICT beim Löschen)
/// → `PaymentMethodsRepositoryException` mit klarer Meldung.
/// * `404` → dito (NotFound-Hinweis im Text).
/// * `401` lassen wir ungefangen durchfliegen — globaler Auth-Handler
/// übernimmt.
class PaymentMethodsRepositoryImpl implements PaymentMethodsRepository {
PaymentMethodsRepositoryImpl(this._api);
final api.HolzleitnerApi _api;
@override
Future<List<PaymentMethod>> list({bool includeInactive = false}) async {
try {
final response = await _api
.getPaymentMethodsApi()
.listPaymentMethods(includeInactive: includeInactive);
final methods = response.data?.methods;
if (methods == null) return const [];
return methods.map((m) => m.toDomain()).toList(growable: false);
} on DioException catch (e) {
throw PaymentMethodsRepositoryException(
_describe(e, 'Laden der Zahlungsmethoden'),
e,
);
}
}
@override
Future<PaymentMethod> create({
required String code,
required String name,
}) async {
try {
final request = api.CreatePaymentMethodRequest((b) {
b
..code = code
..name = name;
});
final response = await _api
.getPaymentMethodsApi()
.createPaymentMethod(createPaymentMethodRequest: request);
final method = response.data?.method;
if (method == null) {
throw const PaymentMethodsRepositoryException(
'Server lieferte leere Antwort beim Anlegen',
);
}
return method.toDomain();
} on DioException catch (e) {
throw PaymentMethodsRepositoryException(
_describe(e, 'Anlegen einer Zahlungsmethode'),
e,
);
}
}
@override
Future<PaymentMethod> update({
required String id,
String? name,
bool? active,
}) async {
try {
final request = api.UpdatePaymentMethodRequest((b) {
if (name != null) b.name = name;
if (active != null) b.active = active;
});
final response =
await _api.getPaymentMethodsApi().updatePaymentMethod(
id: id,
updatePaymentMethodRequest: request,
);
final method = response.data?.method;
if (method == null) {
throw const PaymentMethodsRepositoryException(
'Server lieferte leere Antwort beim Aktualisieren',
);
}
return method.toDomain();
} on DioException catch (e) {
throw PaymentMethodsRepositoryException(
_describe(e, 'Aktualisieren einer Zahlungsmethode'),
e,
);
}
}
@override
Future<void> delete(String id) async {
try {
await _api.getPaymentMethodsApi().deletePaymentMethod(id: id);
} on DioException catch (e) {
throw PaymentMethodsRepositoryException(
_describe(e, 'Löschen einer Zahlungsmethode'),
e,
);
}
}
String _describe(DioException e, String operation) {
final status = e.response?.statusCode;
final body = e.response?.data;
if ((status == 400 || status == 409) &&
body is Map &&
body['message'] != null) {
return body['message'].toString();
}
if (status == 409) {
return 'Zahlungsmethode wird noch von Lieferungen verwendet';
}
if (status == 404) return 'Zahlungsmethode nicht gefunden';
if (status == 401) return 'Sitzung abgelaufen';
return '$operation fehlgeschlagen (HTTP ${status ?? 'unbekannt'})';
}
}

View File

@ -0,0 +1,569 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:holzleitner_api/holzleitner_api.dart' as api;
import 'package:hl_lieferservice/data/mapper/tour_mapper.dart';
import 'package:hl_lieferservice/domain/entity/address.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_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/tour.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/domain/repository/tour_repository.dart';
/// Implementierung gegen den generierten Dio-Client. Übersetzt
/// `DioException` in [TourRepositoryException]. 401 lassen wir
/// ungefangen durchfliegen — der TokenProvider erkennt 401 separat
/// und meldet `AuthSessionExpired`.
class TourRepositoryImpl implements TourRepository {
TourRepositoryImpl(this._api);
final api.HolzleitnerApi _api;
@override
Future<TourSummary?> getMyTourSummaryOfToday() async {
try {
final response = await _api.getToursApi().listMyToursToday();
final tours = response.data?.tours;
if (tours == null || tours.isEmpty) return null;
// Backend liefert die Liste sortiert; wir nehmen die erste Tour.
// Der Fahrer hat aktuell nur eine Tour pro Tag — falls sich das
// ändert, wird hier eine Auswahl-UI nötig.
return tours.first.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Laden der Tour-Übersicht'), e);
}
}
@override
Future<TourDetails> getTourDetails(String tourId) async {
try {
final response = await _api.getToursApi().getTour(tourId: tourId);
final details = response.data;
if (details == null) {
throw const TourRepositoryException(
'Server lieferte leere Tour-Antwort',
);
}
return details.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Laden der Tour'), e);
}
}
@override
Future<TourDetails?> getMyTourDetailsOfToday() async {
final summary = await getMyTourSummaryOfToday();
if (summary == null) return null;
return getTourDetails(summary.tourId);
}
@override
Future<Map<String, int>> setDeliveryOrder({
required String tourId,
required List<String> orderedDeliveryIds,
}) async {
try {
final request = api.SetDeliveryOrderRequest((b) {
b.deliveryIds.replace(orderedDeliveryIds);
});
final response = await _api.getToursApi().setDeliveryOrder(
tourId: tourId,
setDeliveryOrderRequest: request,
);
final order = response.data?.order;
if (order == null) {
throw const TourRepositoryException(
'Server lieferte leere Reihenfolge-Antwort',
);
}
return {for (final e in order) e.deliveryId: e.sortOrder};
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Speichern der Reihenfolge'), e);
}
}
@override
Future<Delivery> assignCarToDelivery({
required String deliveryId,
required String? carId,
}) async {
try {
final request = api.AssignCarRequest((b) {
// `null` ⇒ Backend hebt die Zuweisung auf. built_value lässt das
// im Wire als `"carId": null` durchgehen — vom OpenAPI-Schema so
// gewollt.
b.carId = carId;
});
final response = await _api.getDeliveriesApi().assignCar(
deliveryId: deliveryId,
assignCarRequest: request,
);
final delivery = response.data?.delivery;
if (delivery == null) {
throw const TourRepositoryException(
'Server lieferte leere Delivery-Antwort',
);
}
// Achtung: Der Endpoint gibt `Delivery` *ohne* Items zurück.
// Wir bauen hier eine Domain-Delivery mit leerer Item-Liste —
// der Bloc muss die echten Items aus dem lokalen Aggregat mergen.
return Delivery(
id: delivery.id,
tourId: delivery.tourId,
customerId: delivery.customerId,
contactPersonIds: delivery.contactPersonIds.toList(growable: false),
deliveryAddressSnapshot: delivery.deliveryAddressSnapshot.toDomain(),
erpBelegartId: delivery.erpBelegartId,
erpBelegnummer: delivery.erpBelegnummer,
state: delivery.state.toDomain(),
stateReason: delivery.stateReason,
// Stamm-Endpoint kennt `sortOrder` nicht — Bloc behält den Wert.
sortOrder: 0,
assignedCarId: delivery.assignedCarId,
desiredTime: delivery.desiredTime,
specialAgreements: delivery.specialAgreements,
items: const [],
prepaidAmount: delivery.prepaidAmount,
paymentMethodId: delivery.paymentMethodId,
);
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Fahrzeug-Zuweisung'), e);
}
}
@override
Future<Delivery> cancelDelivery({
required String deliveryId,
required String reason,
}) async {
try {
final request = api.CancelDeliveryRequest((b) => b..reason = reason);
final response = await _api.getDeliveriesApi().cancel(
deliveryId: deliveryId,
cancelDeliveryRequest: request,
);
return _liftDeliveryStub(response.data?.delivery, 'Abbruch');
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Lieferung abbrechen'), e);
}
}
@override
Future<Delivery> holdDelivery({
required String deliveryId,
required String reason,
}) async {
try {
final request = api.HoldDeliveryRequest((b) => b..reason = reason);
final response = await _api.getDeliveriesApi().hold(
deliveryId: deliveryId,
holdDeliveryRequest: request,
);
return _liftDeliveryStub(response.data?.delivery, 'Pausieren');
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Lieferung pausieren'), e);
}
}
@override
Future<Delivery> resumeDelivery({required String deliveryId}) async {
try {
final response =
await _api.getDeliveriesApi().resume(deliveryId: deliveryId);
return _liftDeliveryStub(response.data?.delivery, 'Fortsetzen');
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Lieferung fortsetzen'), e);
}
}
@override
Future<Delivery> completeDelivery({
required String deliveryId,
required List<int> customerSignaturePng,
required List<int> driverSignaturePng,
required bool receiptConfirmed,
required bool notesAcknowledged,
required List<String> acknowledgedNoteIds,
String? paymentMethodId,
String? actorCarId,
bool paymentCollected = false,
}) async {
// multipart/form-data: zwei Signatur-PNGs + ein JSON-Feld mit den
// Bestätigungen. Direkt über die Dio-Instanz, weil der dart-dio-Generator
// für multipart keinen typisierten Body erzeugt (wie beim Bild-Upload).
try {
final acknowledgements = <String, dynamic>{
'receiptConfirmed': receiptConfirmed,
'notesAcknowledged': notesAcknowledged,
'acknowledgedNoteIds': acknowledgedNoteIds,
'paymentCollected': paymentCollected,
if (paymentMethodId != null) 'paymentMethodId': paymentMethodId,
if (actorCarId != null) 'authorCarId': actorCarId,
};
final form = FormData.fromMap({
'customer_signature': MultipartFile.fromBytes(
customerSignaturePng,
filename: 'customer_signature.png',
contentType: DioMediaType.parse('image/png'),
),
'driver_signature': MultipartFile.fromBytes(
driverSignaturePng,
filename: 'driver_signature.png',
contentType: DioMediaType.parse('image/png'),
),
'acknowledgements': jsonEncode(acknowledgements),
});
final response = await _api.dio.post<Map<String, dynamic>>(
'/deliveries/$deliveryId/complete',
data: form,
);
final delivery = response.data?['delivery'] as Map<String, dynamic>?;
if (delivery == null) {
throw const TourRepositoryException(
'Server lieferte leere Delivery-Antwort beim Abschließen',
);
}
return _deliveryStubFromJson(delivery);
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Lieferung abschließen'), e);
}
}
/// Hebt einen Delivery-Stub (Stamm-Endpoint-Response **ohne** Items)
/// in die Domain. Aufrufer muss anschließend `copyWith(items: ..., sortOrder: ...)`
/// aus dem lokalen Aggregat mergen — der Bloc-Handler kümmert sich darum.
Delivery _liftDeliveryStub(api.Delivery? stub, String operation) {
if (stub == null) {
throw TourRepositoryException(
'Server lieferte leere Delivery-Antwort beim $operation',
);
}
return Delivery(
id: stub.id,
tourId: stub.tourId,
customerId: stub.customerId,
contactPersonIds: stub.contactPersonIds.toList(growable: false),
deliveryAddressSnapshot: stub.deliveryAddressSnapshot.toDomain(),
erpBelegartId: stub.erpBelegartId,
erpBelegnummer: stub.erpBelegnummer,
state: stub.state.toDomain(),
stateReason: stub.stateReason,
sortOrder: 0,
assignedCarId: stub.assignedCarId,
desiredTime: stub.desiredTime,
specialAgreements: stub.specialAgreements,
items: const [],
prepaidAmount: stub.prepaidAmount,
paymentMethodId: stub.paymentMethodId,
);
}
@override
Future<DeliveryNote> addDeliveryNote({
required String deliveryId,
String? text,
String? imageAttachment,
String? creditDeliveryItemId,
bool isAmountCreditNote = false,
}) async {
try {
final request = api.CreateDeliveryNoteRequest((b) {
if (text != null) b.text = text;
if (imageAttachment != null) b.imageAttachment = imageAttachment;
if (creditDeliveryItemId != null) {
b.creditDeliveryItemId = creditDeliveryItemId;
}
b.isAmountCreditNote = isAmountCreditNote;
});
final response = await _api.getDeliveriesApi().createNote(
deliveryId: deliveryId,
createDeliveryNoteRequest: request,
);
final note = response.data?.note;
if (note == null) {
throw const TourRepositoryException(
'Server lieferte leere Notiz-Antwort',
);
}
return note.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Notiz anlegen'), e);
}
}
@override
Future<DeliveryNote> updateDeliveryNote({
required String deliveryId,
required String noteId,
String? text,
String? imageAttachment,
}) async {
try {
final request = api.UpdateDeliveryNoteRequest((b) {
if (text != null) b.text = text;
if (imageAttachment != null) b.imageAttachment = imageAttachment;
});
final response = await _api.getDeliveriesApi().updateNote(
deliveryId: deliveryId,
noteId: noteId,
updateDeliveryNoteRequest: request,
);
final note = response.data?.note;
if (note == null) {
throw const TourRepositoryException(
'Server lieferte leere Notiz-Antwort',
);
}
return note.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Notiz aktualisieren'), e);
}
}
@override
Future<void> deleteDeliveryNote({
required String deliveryId,
required String noteId,
}) async {
try {
await _api.getDeliveriesApi().deleteNote(
deliveryId: deliveryId,
noteId: noteId,
);
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Notiz löschen'), e);
}
}
@override
Future<DeliveryNote> uploadDeliveryNoteImage({
required String deliveryId,
required String filename,
required String mime,
required List<int> bytes,
}) async {
// Bewusst direkt über die Dio-Instanz statt über den generierten Client:
// der dart-dio-Generator erzeugt für multipart/form-data keinen
// typisierten Body-Parameter. Der `HolzleitnerAuthInterceptor` an der
// Dio-Instanz hängt den Bearer-Token automatisch an.
try {
final form = FormData.fromMap({
'file': MultipartFile.fromBytes(
bytes,
filename: filename,
contentType: DioMediaType.parse(mime),
),
});
final response = await _api.dio.post<Map<String, dynamic>>(
'/deliveries/$deliveryId/notes/image',
data: form,
);
final note = (response.data?['note']) as Map<String, dynamic>?;
if (note == null) {
throw const TourRepositoryException(
'Server lieferte leere Notiz-Antwort beim Bild-Upload',
);
}
return _noteFromJson(note);
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Bild hochladen'), e);
}
}
@override
Future<DeliveryCredit?> setDeliveryCredit({
required String deliveryId,
required String clientEventId,
required int amountCents,
required String reason,
String? actorCarId,
}) async {
try {
final request = api.DeliveryCreditEventRequest((b) {
b
..clientEventId = clientEventId
..action = api.CreditAction.set_
..amountCents = amountCents
..reason = reason;
if (actorCarId != null) b.authorCarId = actorCarId;
});
final response = await _api.getDeliveriesApi().applyCredit(
deliveryId: deliveryId,
deliveryCreditEventRequest: request,
);
return response.data?.credit?.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Gutschrift setzen'), e);
}
}
@override
Future<DeliveryCredit?> removeDeliveryCredit({
required String deliveryId,
required String clientEventId,
String? actorCarId,
}) async {
try {
final request = api.DeliveryCreditEventRequest((b) {
b
..clientEventId = clientEventId
..action = api.CreditAction.remove;
if (actorCarId != null) b.authorCarId = actorCarId;
});
final response = await _api.getDeliveriesApi().applyCredit(
deliveryId: deliveryId,
deliveryCreditEventRequest: request,
);
return response.data?.credit?.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Gutschrift entfernen'), e);
}
}
@override
Future<DeliveryServiceValue> setDeliveryService({
required String deliveryId,
required String serviceId,
bool? boolValue,
int? numericValue,
String? actorCarId,
}) async {
try {
final request = api.SetDeliveryServiceRequest((b) {
if (boolValue != null) b.boolValue = boolValue;
if (numericValue != null) b.numericValue = numericValue;
if (actorCarId != null) b.authorCarId = actorCarId;
});
final response = await _api.getDeliveriesApi().setService(
deliveryId: deliveryId,
serviceId: serviceId,
setDeliveryServiceRequest: request,
);
final value = response.data?.value;
if (value == null) {
throw const TourRepositoryException(
'Server lieferte leeren Service-Wert',
);
}
return value.toDomain();
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Service setzen'), e);
}
}
@override
Future<void> removeDeliveryService({
required String deliveryId,
required String serviceId,
}) async {
try {
await _api.getDeliveriesApi().deleteServiceValue(
deliveryId: deliveryId,
serviceId: serviceId,
);
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Service entfernen'), e);
}
}
/// Mappt das rohe Note-JSON (camelCase) der Upload-Antwort in die Domain.
/// Eigene Mini-Deserialisierung, weil dieser Pfad nicht über den
/// generierten Client (mit built_value) läuft.
/// Baut aus der rohen JSON-Map (Stamm-Endpoint-Response **ohne** Items)
/// eine Domain-Delivery. Wie [_liftDeliveryStub], aber für die direkten
/// Dio-Calls (multipart), die keinen typisierten Body liefern. Aufrufer
/// merged Items/sortOrder aus dem lokalen Aggregat.
Delivery _deliveryStubFromJson(Map<String, dynamic> j) {
final snap = j['deliveryAddressSnapshot'] as Map<String, dynamic>;
return Delivery(
id: j['id'] as String,
tourId: j['tourId'] as String,
customerId: j['customerId'] as String,
contactPersonIds:
(j['contactPersonIds'] as List).cast<String>().toList(growable: false),
deliveryAddressSnapshot: Address(
street: snap['street'] as String,
houseNumber: snap['houseNumber'] as String,
postalCode: snap['postalCode'] as String,
city: snap['city'] as String,
country: snap['country'] as String,
),
erpBelegartId: (j['erpBelegartId'] as num).toInt(),
erpBelegnummer: j['erpBelegnummer'] as String,
state: _deliveryStateFromWire(j['state'] as String),
stateReason: j['stateReason'] as String?,
sortOrder: 0,
assignedCarId: j['assignedCarId'] as String?,
desiredTime: j['desiredTime'] as String?,
specialAgreements: j['specialAgreements'] as String?,
items: const [],
prepaidAmount: (j['prepaidAmount'] as num).toDouble(),
paymentMethodId: j['paymentMethodId'] as String,
);
}
DeliveryState _deliveryStateFromWire(String value) {
switch (value) {
case 'active':
return DeliveryState.active;
case 'held':
return DeliveryState.held;
case 'canceled':
return DeliveryState.canceled;
case 'completed':
return DeliveryState.completed;
default:
throw TourRepositoryException('Unbekannter DeliveryState: $value');
}
}
DeliveryNote _noteFromJson(Map<String, dynamic> j) => DeliveryNote(
id: j['id'] as String,
deliveryId: j['deliveryId'] as String,
text: j['text'] as String?,
imageAttachment: j['imageAttachment'] as String?,
authorPersonalnummer: (j['authorPersonalnummer'] as num).toInt(),
authorCarId: j['authorCarId'] as String?,
creditDeliveryItemId: j['creditDeliveryItemId'] as String?,
isAmountCreditNote: (j['isAmountCreditNote'] as bool?) ?? false,
createdAt: DateTime.parse(j['createdAt'] as String),
);
@override
Future<Map<String, ScanOutcome>> applyScans(List<ScanIntent> intents) async {
if (intents.isEmpty) return const {};
try {
final request = api.ApplyScansRequest((b) {
b.scans.replace(intents.map((i) => i.toWire()));
});
final response = await _api.getScansApi().applyScans(
applyScansRequest: request,
);
final results = response.data?.results;
if (results == null) {
throw const TourRepositoryException(
'Server lieferte leere Scan-Antwort',
);
}
return {for (final r in results) r.clientScanId: r.toDomain()};
} on DioException catch (e) {
throw TourRepositoryException(_describe(e, 'Scans anwenden'), e);
}
}
String _describe(DioException e, String operation) {
final status = e.response?.statusCode;
final body = e.response?.data;
if (status == 400 && body is Map && body['message'] != null) {
return '$operation fehlgeschlagen: ${body['message']}';
}
if (status == 401) return 'Sitzung abgelaufen';
if (status == 403) return 'Keine Berechtigung';
if (status == 404) return 'Tour oder Lieferung nicht gefunden';
return '$operation fehlgeschlagen (HTTP ${status ?? 'unbekannt'})';
}
}

View File

@ -0,0 +1,55 @@
/// Postanschrift — Value-Object, identitätslos.
///
/// Tritt im Domain an drei Stellen auf: am `Customer` (Stamm-Adresse) und
/// als `deliveryAddressSnapshot` auf der `Delivery` (eingefrorene Kopie der
/// Adresse zum Zeitpunkt der Belegerzeugung, damit nachträgliche Änderungen
/// am Stammdatensatz die ausgelieferte Tour nicht „verschieben"). Spiegelt
/// das Backend-DTO `Address` 1:1.
class Address {
const Address({
required this.street,
required this.houseNumber,
required this.postalCode,
required this.city,
required this.country,
});
final String street;
final String houseNumber;
final String postalCode;
final String city;
final String country;
/// Einzeilige Darstellung für Listen/Header.
String get oneLine =>
'$street $houseNumber, $postalCode $city';
Address copyWith({
String? street,
String? houseNumber,
String? postalCode,
String? city,
String? country,
}) {
return Address(
street: street ?? this.street,
houseNumber: houseNumber ?? this.houseNumber,
postalCode: postalCode ?? this.postalCode,
city: city ?? this.city,
country: country ?? this.country,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Address &&
other.street == street &&
other.houseNumber == houseNumber &&
other.postalCode == postalCode &&
other.city == city &&
other.country == country;
@override
int get hashCode => Object.hash(street, houseNumber, postalCode, city, country);
}

View File

@ -0,0 +1,45 @@
/// Stammdatensatz für einen Artikel (Ware).
///
/// Der Domain-Artikel kennt — anders als das alte ERPframe-Modell — keine
/// Eltern-Kind-Beziehungen mehr. Stücklisten (BOM/Komponenten) werden im
/// neuen Backend als gleichrangige `DeliveryItem`s mit gesetztem
/// `komponentenArtikelNr` modelliert; der Treiber scannt einfach jedes Item
/// separat. Hier deshalb absichtlich kein `components`/`parent`-Feld.
class Article {
const Article({
required this.id,
required this.articleNumber,
required this.name,
required this.scannable,
this.defaultWarehouseId,
});
final String id;
final String articleNumber;
final String name;
/// Nicht-scanbar = wird nicht über den Scanner durchgereicht (z. B.
/// Dienstleistung, Versandkosten). In der Loading-Phase ausgeblendet.
final bool scannable;
/// Lager-Default für diesen Artikel; das tatsächlich relevante Lager pro
/// Lieferung steht aber am `DeliveryItem.warehouseId`. Wird nur als
/// UX-Hinweis verwendet.
final String? defaultWarehouseId;
Article copyWith({
String? id,
String? articleNumber,
String? name,
bool? scannable,
String? defaultWarehouseId,
}) {
return Article(
id: id ?? this.id,
articleNumber: articleNumber ?? this.articleNumber,
name: name ?? this.name,
scannable: scannable ?? this.scannable,
defaultWarehouseId: defaultWarehouseId ?? this.defaultWarehouseId,
);
}
}

View File

@ -0,0 +1,226 @@
/// Adress-Rolle eines Beleg-Kontakts. Spiegelt die fünf Adress-FKs am
/// ERP-`Belegkopf` (bzw. den Umweg über `Kunden.AdressId`). Die App nutzt
/// das primär als Gruppierungs-Label in der Detail-Ansicht.
enum ContactRole {
/// `Belegkopf.AdressId` — die „eigentliche" Belegadresse.
header,
/// `Belegkopf.LieferAdressId` — kann von der Belegadresse abweichen.
delivery,
/// `Belegkopf.RechnungsAdressId`.
billing,
/// `Belegkopf.AnsprechpartnerId` — verlinkt eine Person, nicht eine Firma.
contactPerson,
/// `Kunden.AdressId` (über `Belegkopf.KundenId`). Die Stammadresse des
/// Kunden — dient als Fallback, wenn die belegspezifischen Adressen leer
/// sind.
customerMaster;
/// Wire-Repräsentation aus dem Backend (serde `snake_case`).
static ContactRole fromWire(String value) {
switch (value) {
case 'header':
return ContactRole.header;
case 'delivery':
return ContactRole.delivery;
case 'billing':
return ContactRole.billing;
case 'contact_person':
return ContactRole.contactPerson;
case 'customer_master':
return ContactRole.customerMaster;
default:
throw StateError('Unbekannte ContactRole vom Backend: $value');
}
}
/// Deutscher Label-Text für die UI.
String get label {
switch (this) {
case ContactRole.header:
return 'Belegadresse';
case ContactRole.delivery:
return 'Lieferadresse';
case ContactRole.billing:
return 'Rechnungsadresse';
case ContactRole.contactPerson:
return 'Ansprechpartner';
case ContactRole.customerMaster:
return 'Kundenstamm';
}
}
}
/// Art eines Kommunikationskanals. `fax` wird vom Backend bewusst nicht
/// mitgeführt — die App braucht es nicht.
enum ContactKind {
phone,
mobile,
email,
web;
static ContactKind fromWire(String value) {
switch (value) {
case 'phone':
return ContactKind.phone;
case 'mobile':
return ContactKind.mobile;
case 'email':
return ContactKind.email;
case 'web':
return ContactKind.web;
default:
throw StateError('Unbekannter ContactKind vom Backend: $value');
}
}
}
/// Eine Adress-Quelle, die am Beleg hängt — z. B. Lieferadresse oder
/// Ansprechpartner. Der Namensblock kommt direkt aus ERP-`Adressen`
/// (`Anrede`/`Titel`/`Name1..3`/`Abteilung`/`Funktion`); die eigentlichen
/// Telefonnummern, E-Mails etc. liegen verteilt in zugehörigen
/// [ContactChannel]s und werden in [TourDetails.channelsOf] zusammengeführt.
class ContactSource {
const ContactSource({
required this.id,
required this.deliveryId,
required this.role,
this.anrede,
this.titel,
this.name1,
this.name2,
this.name3,
this.abteilung,
this.funktion,
});
final String id;
final String deliveryId;
final ContactRole role;
final String? anrede;
final String? titel;
final String? name1;
final String? name2;
final String? name3;
final String? abteilung;
final String? funktion;
/// Zusammengesetzte Anzeige des Namens — Anrede + Titel + Name1..3 in
/// dieser Reihenfolge, leere Felder werden übersprungen. Gibt `null`
/// zurück, wenn die Quelle gar keinen Namen trägt (kann vorkommen, wenn
/// nur Telefonnummern hinterlegt sind).
String? get displayName {
final parts = <String>[
if (anrede != null && anrede!.isNotEmpty) anrede!,
if (titel != null && titel!.isNotEmpty) titel!,
if (name1 != null && name1!.isNotEmpty) name1!,
if (name2 != null && name2!.isNotEmpty) name2!,
if (name3 != null && name3!.isNotEmpty) name3!,
];
if (parts.isEmpty) return null;
return parts.join(' ');
}
/// Funktionale Zusatzinfo (z. B. „Buchhaltung · Leitung"). Leere
/// Komponenten werden ausgeblendet.
String? get subtitle {
final parts = <String>[
if (abteilung != null && abteilung!.isNotEmpty) abteilung!,
if (funktion != null && funktion!.isNotEmpty) funktion!,
];
if (parts.isEmpty) return null;
return parts.join(' · ');
}
}
/// Ein einzelner Kommunikationskanal (Telefon, Mobil, E-Mail, Web). Mehrere
/// pro [ContactSource] möglich; die [position] (1-basiert) erhält die
/// ERP-Reihenfolge — Position 1 ist der primäre Kanal, Position 2 das
/// erste Zusatzfeld usw.
class ContactChannel {
const ContactChannel({
required this.id,
required this.sourceId,
required this.kind,
required this.position,
required this.value,
});
final String id;
final String sourceId;
final ContactKind kind;
final int position;
final String value;
}
/// Zusammengeführte Sicht auf 1..n [ContactSource]s, die fachlich denselben
/// Kontakt darstellen — gleicher Namensblock UND gleiche Channel-Liste
/// (also exakt dieselbe Adresse im ERP, nur über verschiedene FKs am
/// `Belegkopf` referenziert: typischerweise `AdressId` und `Kunden.AdressId`,
/// die in den allermeisten Belegen identisch sind).
///
/// Die App rendert pro Lieferung eine Karte je Eintrag; `roles` listet
/// alle Rollen auf, die zu diesem Eintrag beitragen (z. B. „Belegadresse ·
/// Kundenstamm"). Die Channels werden 1:1 von der ersten Quelle übernommen
/// — alle Quellen in einer Gruppe haben dieselben.
class MergedContactSource {
const MergedContactSource({
required this.roles,
required this.anrede,
required this.titel,
required this.name1,
required this.name2,
required this.name3,
required this.abteilung,
required this.funktion,
required this.channels,
});
/// Alle Rollen, die diesen zusammengeführten Kontakt liefern.
/// Reihenfolge wie in der enum-Definition: header → delivery → billing
/// → contactPerson → customerMaster, damit das Label stabil bleibt.
final List<ContactRole> roles;
final String? anrede;
final String? titel;
final String? name1;
final String? name2;
final String? name3;
final String? abteilung;
final String? funktion;
/// Channels in der gleichen Reihenfolge, wie das Backend sie pro Quelle
/// liefert (kind + ERP-Position).
final List<ContactChannel> channels;
/// Zusammengesetzter Anzeigename — identisch zu [ContactSource.displayName].
String? get displayName {
final parts = <String>[
if (anrede != null && anrede!.isNotEmpty) anrede!,
if (titel != null && titel!.isNotEmpty) titel!,
if (name1 != null && name1!.isNotEmpty) name1!,
if (name2 != null && name2!.isNotEmpty) name2!,
if (name3 != null && name3!.isNotEmpty) name3!,
];
if (parts.isEmpty) return null;
return parts.join(' ');
}
String? get subtitle {
final parts = <String>[
if (abteilung != null && abteilung!.isNotEmpty) abteilung!,
if (funktion != null && funktion!.isNotEmpty) funktion!,
];
if (parts.isEmpty) return null;
return parts.join(' · ');
}
/// Header-Label für die UI — alle Rollen mit `·` getrennt, in
/// Enum-Reihenfolge.
String get rolesLabel => roles.map((r) => r.label).join(' · ');
}

View File

@ -0,0 +1,53 @@
import 'address.dart';
/// Kunden-Stammdatensatz. Ein Kunde kann mehrere `CustomerContact`s haben
/// (Ehepartner, Hausverwalter, …); diese werden separat in der
/// `TourDetails.contacts`-Map geführt.
class Customer {
const Customer({
required this.id,
required this.name,
required this.erpCustomerId,
required this.address,
});
final String id;
final String name;
/// ERP-Kundennummer (Legacy). Wird in der App nur informativ in der
/// Detail-Ansicht angezeigt.
final int erpCustomerId;
final Address address;
Customer copyWith({
String? id,
String? name,
int? erpCustomerId,
Address? address,
}) {
return Customer(
id: id ?? this.id,
name: name ?? this.name,
erpCustomerId: erpCustomerId ?? this.erpCustomerId,
address: address ?? this.address,
);
}
}
/// Ansprechpartner zu einem Kunden. Optional, daher als eigene Liste in
/// `TourDetails` — eine Lieferung referenziert n Kontakte per Id.
class CustomerContact {
const CustomerContact({
required this.id,
required this.customerId,
required this.name,
this.phone,
this.email,
});
final String id;
final String customerId;
final String name;
final String? phone;
final String? email;
}

View File

@ -0,0 +1,152 @@
import 'address.dart';
import 'delivery_item.dart';
/// Lebenszyklus einer Lieferung.
///
/// - `active`: Standard nach Anlage; Fahrer kann scannen/ausliefern.
/// - `held`: Pausiert (Kunde nicht da, Termin verschoben) — kein Bearbeitungsfortschritt.
/// - `canceled`: Abgebrochen — wird nicht mehr ausgeliefert.
/// - `completed`: Abgeschlossen — Signatur und Notizen sind hinterlegt.
enum DeliveryState { active, held, canceled, completed }
/// Eine einzelne Auslieferung an einen Kunden innerhalb einer Tour.
///
/// Anders als im alten Modell trägt `Delivery` hier ausschließlich
/// Logistik-Daten — keine Preise, keine Rabatte, keine Zahlungsoptionen.
/// Diese ERP-Themen sind in Phase C+D-2 absichtlich nicht migriert und
/// hängen hinter `FeatureFlags`.
class Delivery {
const Delivery({
required this.id,
required this.tourId,
required this.customerId,
required this.contactPersonIds,
required this.deliveryAddressSnapshot,
required this.erpBelegartId,
required this.erpBelegnummer,
required this.state,
required this.sortOrder,
required this.items,
required this.prepaidAmount,
required this.paymentMethodId,
this.assignedCarId,
this.desiredTime,
this.specialAgreements,
this.stateReason,
});
final String id;
final String tourId;
final String customerId;
/// 0..n Kontakte am Kunden, die für diese Lieferung relevant sind.
/// Lookup über `TourDetails.contactById`.
final List<String> contactPersonIds;
/// Eingefrorene Lieferadresse zum Zeitpunkt der Belegerzeugung — bleibt
/// stabil, auch wenn die Stammadresse am Kunden später geändert wird.
final Address deliveryAddressSnapshot;
/// ERP-Belegart (Lieferschein, Rechnung, …) und -Nummer. Für die App nur
/// informativ; in Notizen/Reklamationen ist die Belegnummer der vom
/// Kunden verständliche Bezugspunkt.
final int erpBelegartId;
final String erpBelegnummer;
final DeliveryState state;
/// Optionaler Klartext, warum `state` auf `held`/`canceled` steht. Vom
/// Backend nicht-leer erzwungen, sobald ein Reason-pflichtiger Zustand
/// gesetzt wird.
final String? stateReason;
/// Sortier-Reihenfolge innerhalb der Tour, gesetzt durch
/// `PUT /tours/{id}/delivery-order`. Niedriger = früher.
final int sortOrder;
/// UUID des Fahrzeugs, dem diese Lieferung beim Laden zugewiesen wurde.
/// `null` = noch nicht zugewiesen.
final String? assignedCarId;
/// Bei Bestellung schon bezahlter Betrag in EUR. `0.0` wenn der Kunde
/// alles bei Lieferung zahlt. Wird vom ERP-Sync gesetzt.
final double prepaidAmount;
/// FK auf eine `PaymentMethod` (UUID). Auflösung zu Display-Name und
/// Aktiv-Status geht über die Stammdaten-Liste, die die App separat
/// lädt — nicht hier embeddet, damit das Tour-Aggregat klein bleibt.
final String paymentMethodId;
final String? desiredTime;
final String? specialAgreements;
final List<DeliveryItem> items;
// ─── Abgeleitete Sicht-Eigenschaften ──────────────────────────────────
/// Nur Items, die der Treiber tatsächlich scannen muss. Nicht-scanbare
/// Artikel (Dienstleistungen, Versand) sowie bereits entfernte Items
/// werden nicht mitgezählt.
Iterable<DeliveryItem> scannableItems(
bool Function(String articleId) isScannable,
) sync* {
for (final item in items) {
if (item.isRemoved) continue;
if (!isScannable(item.articleId)) continue;
yield item;
}
}
/// `true`, sobald *alle* scanbaren Items dieser Lieferung als `done`
/// markiert sind. Wird in der Loading-Übersicht angezeigt und
/// kontrolliert in der Detail-Phase den Übergang zur Signatur.
bool allScannableItemsDone(bool Function(String articleId) isScannable) {
final scannables = scannableItems(isScannable).toList();
if (scannables.isEmpty) return false;
return scannables.every((item) => item.isDone);
}
Delivery copyWith({
String? id,
String? tourId,
String? customerId,
List<String>? contactPersonIds,
Address? deliveryAddressSnapshot,
int? erpBelegartId,
String? erpBelegnummer,
DeliveryState? state,
String? stateReason,
int? sortOrder,
String? assignedCarId,
Object? desiredTime = _sentinel,
Object? specialAgreements = _sentinel,
List<DeliveryItem>? items,
double? prepaidAmount,
String? paymentMethodId,
}) {
return Delivery(
id: id ?? this.id,
tourId: tourId ?? this.tourId,
customerId: customerId ?? this.customerId,
contactPersonIds: contactPersonIds ?? this.contactPersonIds,
deliveryAddressSnapshot: deliveryAddressSnapshot ?? this.deliveryAddressSnapshot,
erpBelegartId: erpBelegartId ?? this.erpBelegartId,
erpBelegnummer: erpBelegnummer ?? this.erpBelegnummer,
state: state ?? this.state,
stateReason: stateReason ?? this.stateReason,
sortOrder: sortOrder ?? this.sortOrder,
assignedCarId: assignedCarId ?? this.assignedCarId,
desiredTime: identical(desiredTime, _sentinel)
? this.desiredTime
: desiredTime as String?,
specialAgreements: identical(specialAgreements, _sentinel)
? this.specialAgreements
: specialAgreements as String?,
items: items ?? this.items,
prepaidAmount: prepaidAmount ?? this.prepaidAmount,
paymentMethodId: paymentMethodId ?? this.paymentMethodId,
);
}
}
const Object _sentinel = Object();

View File

@ -0,0 +1,20 @@
/// Aktuelle Betrags-Gutschrift einer Lieferung (Geld-Nachlass, unabhängig von
/// Stückzahl). Server-seitig aus dem append-only `delivery_credit_audit`
/// abgeleitet (jüngstes Ereignis); existiert nur, solange der letzte Stand
/// `set` ist.
class DeliveryCredit {
const DeliveryCredit({
required this.deliveryId,
required this.amountCents,
required this.reason,
});
final String deliveryId;
/// Betrag in Cent (> 0, ≤ 15000).
final int amountCents;
final String reason;
/// Betrag in ganzen Euro (die Gutschrift läuft in 10-€-Schritten).
int get amountEuros => (amountCents / 100).round();
}

View File

@ -0,0 +1,106 @@
import 'scan_progress.dart';
/// Eine Belegzeile innerhalb einer Lieferung.
///
/// Verweist über `articleId` auf den Artikel-Stamm (lookup via
/// `TourDetails.articleById`) und über `warehouseId` auf das Lager. Die
/// Soll-/Ist-Quantitäten leben hier: `requiredQuantity` ist statisch (ERP),
/// `scanProgress.scannedQuantity` wandert mit jedem Scan nach oben.
///
/// `komponentenArtikelNr` markiert Stücklisten-Komponenten. Im neuen
/// Backend gibt es **keine** Parent-/Child-Hierarchie mehr — jedes Item ist
/// gleichrangig; das Feld dient nur noch der Anzeige ("Teil von X") und
/// hat keinerlei Scan-Semantik.
class DeliveryItem {
const DeliveryItem({
required this.id,
required this.deliveryId,
required this.articleId,
required this.warehouseId,
required this.belegzeilenNr,
required this.requiredQuantity,
required this.scanProgress,
this.unitPrice = 0,
this.komponentenArtikelNr,
this.parentArtikelNr,
});
final String id;
final String deliveryId;
final String articleId;
final String warehouseId;
/// ERP-Belegzeilen-Nummer. Bestimmt die Reihenfolge der Items in der
/// Detail-Ansicht (aufsteigend).
final int belegzeilenNr;
final int requiredQuantity;
final ScanProgress scanProgress;
/// Stückpreis (brutto, EUR) aus dem ERP-Sync.
final double unitPrice;
final String? komponentenArtikelNr;
/// Artikelnummer des Oberartikels, zu dem diese Komponente gehört (aus dem
/// Sync). `null` bei Oberartikeln/regulären Zeilen. Die Liste rückt
/// Komponenten unter ihrem Oberartikel ein.
final String? parentArtikelNr;
/// `true`, wenn dieses Item eine Stücklisten-Komponente ist (gehört unter
/// einen Oberartikel).
bool get isComponent => parentArtikelNr != null;
// ─── Abgeleitete Sicht-Eigenschaften ──────────────────────────────────
/// Tatsächlich auszuliefernde Menge = Soll Gutschrift. Nie negativ.
int get deliveredQuantity {
final d = requiredQuantity - scanProgress.creditedQuantity;
return d < 0 ? 0 : d;
}
/// Wert der ausgelieferten Menge dieser Position (brutto, EUR).
double get lineTotal => unitPrice * deliveredQuantity;
/// Vollständig gescannt (Status `done` oder Ist ≥ Soll).
bool get isDone =>
scanProgress.status == ScanStatus.done ||
scanProgress.scannedQuantity >= requiredQuantity;
/// Aktuell pausiert.
bool get isHeld => scanProgress.status == ScanStatus.held;
/// Nach dem Laden wieder entfernt.
bool get isRemoved => scanProgress.status == ScanStatus.removed;
/// Noch offene Restmenge (für Loading-UI). Nicht negativ.
int get remainingQuantity {
final remaining = requiredQuantity - scanProgress.scannedQuantity;
return remaining < 0 ? 0 : remaining;
}
DeliveryItem copyWith({
String? id,
String? deliveryId,
String? articleId,
String? warehouseId,
int? belegzeilenNr,
int? requiredQuantity,
ScanProgress? scanProgress,
double? unitPrice,
String? komponentenArtikelNr,
String? parentArtikelNr,
}) {
return DeliveryItem(
id: id ?? this.id,
deliveryId: deliveryId ?? this.deliveryId,
articleId: articleId ?? this.articleId,
warehouseId: warehouseId ?? this.warehouseId,
belegzeilenNr: belegzeilenNr ?? this.belegzeilenNr,
requiredQuantity: requiredQuantity ?? this.requiredQuantity,
scanProgress: scanProgress ?? this.scanProgress,
unitPrice: unitPrice ?? this.unitPrice,
komponentenArtikelNr: komponentenArtikelNr ?? this.komponentenArtikelNr,
parentArtikelNr: parentArtikelNr ?? this.parentArtikelNr,
);
}
}

View File

@ -0,0 +1,83 @@
/// Notiz an einer Lieferung. Text und/oder Bildanhang können gesetzt sein —
/// das Backend erzwingt nicht-leer für mindestens einen der beiden.
///
/// `imageAttachment` ist die UUID des hinterlegten Bildes; das eigentliche
/// Binary wird über einen separaten Endpoint geladen (in einer späteren
/// Phase modelliert).
class DeliveryNote {
const DeliveryNote({
required this.id,
required this.deliveryId,
required this.authorPersonalnummer,
required this.createdAt,
this.text,
this.imageAttachment,
this.authorCarId,
this.creditDeliveryItemId,
this.isAmountCreditNote = false,
this.imageAttachmentDeleted = false,
});
final String id;
final String deliveryId;
final String? text;
final String? imageAttachment;
/// Personalnummer des Fahrers (aus dem JWT zum Zeitpunkt der Erstellung).
/// `int` weil im JWT als numerischer Claim transportiert.
final int authorPersonalnummer;
/// Fahrzeug, mit dem die Notiz erstellt wurde (Audit-Spur, optional).
final String? authorCarId;
/// Gesetzt, wenn die Notiz als Gutschrift-Grund zu einer Belegzeile
/// angelegt wurde (deren `DeliveryItem`-Id). Erlaubt es, die Notiz beim
/// Zurücknehmen der Gutschrift (Unremove) gezielt wieder zu löschen.
final String? creditDeliveryItemId;
/// `true`, wenn die Notiz den Grund einer Betrags-Gutschrift dokumentiert
/// (Lieferungs-Ebene). Wird beim Entfernen der Gutschrift gezielt gelöscht.
final bool isAmountCreditNote;
/// `true`, wenn die lokale Bilddatei nach erfolgreichem Report-Upload
/// gelöscht wurde — das Bild steckt dann im Lieferbericht (DOCUframe).
/// Die UI zeigt statt der Vorschau einen Hinweis.
final bool imageAttachmentDeleted;
final DateTime createdAt;
DeliveryNote copyWith({
String? id,
String? deliveryId,
Object? text = _sentinel,
Object? imageAttachment = _sentinel,
int? authorPersonalnummer,
Object? authorCarId = _sentinel,
Object? creditDeliveryItemId = _sentinel,
bool? isAmountCreditNote,
bool? imageAttachmentDeleted,
DateTime? createdAt,
}) {
return DeliveryNote(
id: id ?? this.id,
deliveryId: deliveryId ?? this.deliveryId,
text: identical(text, _sentinel) ? this.text : text as String?,
imageAttachment: identical(imageAttachment, _sentinel)
? this.imageAttachment
: imageAttachment as String?,
authorPersonalnummer: authorPersonalnummer ?? this.authorPersonalnummer,
authorCarId: identical(authorCarId, _sentinel)
? this.authorCarId
: authorCarId as String?,
creditDeliveryItemId: identical(creditDeliveryItemId, _sentinel)
? this.creditDeliveryItemId
: creditDeliveryItemId as String?,
isAmountCreditNote: isAmountCreditNote ?? this.isAmountCreditNote,
imageAttachmentDeleted:
imageAttachmentDeleted ?? this.imageAttachmentDeleted,
createdAt: createdAt ?? this.createdAt,
);
}
}
const Object _sentinel = Object();

View File

@ -0,0 +1,15 @@
/// Pro-Lieferung gesetzter Wert eines Service. Je nach Service-Typ ist genau
/// einer der beiden Slots gefüllt.
class DeliveryServiceValue {
const DeliveryServiceValue({
required this.deliveryId,
required this.serviceId,
this.boolValue,
this.numericValue,
});
final String deliveryId;
final String serviceId;
final bool? boolValue;
final int? numericValue;
}

View File

@ -0,0 +1,38 @@
/// Zahlungs-Stammdatensatz — spiegelt das Backend-Aggregat `PaymentMethod`.
///
/// `code` ist der stabile Programm-Identifier (z. B. `"cash"`,
/// `"invoice"`); UI-Code kann darüber spezielle Methoden referenzieren,
/// ohne die UUID kennen zu müssen. `active = false` ist Soft-Delete —
/// die Methode bleibt für historische Lieferungen referenzierbar,
/// taucht aber in der Auswahl bei neuen Lieferungen nicht mehr auf.
class PaymentMethod {
const PaymentMethod({
required this.id,
required this.code,
required this.name,
required this.active,
required this.createdAt,
});
final String id;
final String code;
final String name;
final bool active;
final DateTime createdAt;
PaymentMethod copyWith({
String? id,
String? code,
String? name,
bool? active,
DateTime? createdAt,
}) {
return PaymentMethod(
id: id ?? this.id,
code: code ?? this.code,
name: name ?? this.name,
active: active ?? this.active,
createdAt: createdAt ?? this.createdAt,
);
}
}

View File

@ -0,0 +1,93 @@
/// Vom Treiber ausgelöstes Scan-Ereignis, bevor es serverseitig
/// angewendet wurde.
///
/// `clientScanId` ist ein vom Client generierter UUID-Schlüssel und dient
/// als **Idempotenz-Anker**: der Server speichert ihn beim ersten Apply
/// und antwortet auf jeden weiteren Request mit derselben Id mit
/// `duplicate` statt einer zweiten Anwendung. So bleibt Network-Retry
/// (z. B. nach Verbindungsabbruch beim ersten POST) bedeutungslos.
///
/// `clientScannedAt` ist die Wall-Clock-Zeit am Gerät zum Zeitpunkt des
/// Scans — der Server nutzt das nur als Audit-Spur, sortiert aber selbst
/// nach Server-Empfangszeit, sodass eine schiefe Uhr am Phone die
/// Reihenfolge nicht durcheinanderbringt.
class ScanIntent {
const ScanIntent({
required this.clientScanId,
required this.clientScannedAt,
required this.deliveryItemId,
required this.action,
this.actorCarId,
this.reason,
this.quantity,
this.manual = false,
});
final String clientScanId;
final DateTime clientScannedAt;
final String deliveryItemId;
final ScanAction action;
/// `true`, wenn der Fahrer die Position manuell als geladen bestätigt hat
/// (Fallback ohne Barcode). Reine Audit-Information; Default `false`.
final bool manual;
/// Menge für `remove` / `unremove` (Mengen-Gutschrift): wie viele Stück
/// der Belegzeile gutgeschrieben bzw. wiederhergestellt werden. `null` =
/// ganze Restmenge. Bei `scan`/`unscan`/`hold`/`unhold` ignoriert.
final int? quantity;
/// Fahrzeug, mit dem gescannt wurde — Audit-Spur. Optional, aber die
/// App schickt ihn in der Loading-Phase immer mit, weil das Auto zu
/// dem Zeitpunkt definitiv gewählt ist.
final String? actorCarId;
/// Klartext-Begründung. Bei `unscan` / `hold` / `remove` vom Backend
/// erwartet, bei `scan` / `unhold` ignoriert.
final String? reason;
}
/// Auswirkung eines Scan-Ereignisses auf die Pipeline eines Items.
/// Spiegel des Backend-Enums `AuditAction`.
///
/// `unremove` ist die Umkehrung von `remove`: setzt ein `Removed`-Item
/// zurück auf `InProgress` (oder `Done`, falls die Soll-Menge schon
/// erreicht war). Der ursprüngliche `remove`-Audit-Eintrag bleibt
/// erhalten — `unremove` erzeugt einen eigenen Eintrag, sodass die
/// Historie der Korrektur vollständig nachvollziehbar bleibt.
enum ScanAction { scan, unscan, hold, unhold, remove, unremove }
/// Ergebnis eines Apply-Versuchs vom Server.
class ScanOutcome {
const ScanOutcome({
required this.clientScanId,
required this.status,
this.deliveryItemId,
this.reason,
});
final String clientScanId;
final ScanOutcomeStatus status;
/// Bei `applied` und `duplicate` immer gesetzt, bei `rejected` häufig
/// `null` (z. B. wenn die Id beim Server gar nicht ankam).
final String? deliveryItemId;
/// Bei `rejected` die Server-Begründung — Standard-Text in der UI.
final String? reason;
}
enum ScanOutcomeStatus {
/// Server hat den Scan angewendet — `scannedQuantity` ist hochgezählt
/// oder Status hat sich geändert.
applied,
/// Server hat denselben `clientScanId` schon einmal verarbeitet —
/// kein Effekt, aber auch kein Fehler.
duplicate,
/// Server hat den Scan abgelehnt (z. B. Item gehört zu fremder
/// Lieferung, Soll-Menge schon voll, Item ist auf `removed`). UI muss
/// optimistische Mutation zurückrollen.
rejected,
}

View File

@ -0,0 +1,48 @@
/// Status der Scan-Pipeline eines einzelnen `DeliveryItem`.
///
/// - `inProgress`: Soll-Menge noch nicht erreicht, Scanner darf weiterzählen.
/// - `done`: Soll-Menge erreicht; weitere Scans werden serverseitig abgewiesen.
/// - `held`: Pausiert (z. B. „Ware beschädigt, klärt der Fahrer mit dem Lager") —
/// `ScanProgress.heldReason` trägt die Begründung.
/// - `removed`: Item wurde nach dem Laden wieder abgebucht (Retoure, Falschladung).
enum ScanStatus { inProgress, done, held, removed }
/// Embedded Value-Object am `DeliveryItem`. Beschreibt, wie weit der Fahrer
/// mit dem Scannen dieses Items ist — *nicht*, wo das Item logistisch steht.
class ScanProgress {
const ScanProgress({
required this.status,
required this.scannedQuantity,
required this.lastUpdatedAt,
this.creditedQuantity = 0,
this.heldReason,
});
final ScanStatus status;
final int scannedQuantity;
/// Als Gutschrift entfernte Menge (0..=requiredQuantity). Eigene Dimension
/// neben [scannedQuantity]: „wie viele Stück dieser Zeile hat der Kunde
/// nicht angenommen". `status == removed` entspricht voller Gutschrift
/// (creditedQuantity == requiredQuantity).
final int creditedQuantity;
final DateTime lastUpdatedAt;
final String? heldReason;
ScanProgress copyWith({
ScanStatus? status,
int? scannedQuantity,
int? creditedQuantity,
DateTime? lastUpdatedAt,
String? heldReason,
}) {
return ScanProgress(
status: status ?? this.status,
scannedQuantity: scannedQuantity ?? this.scannedQuantity,
creditedQuantity: creditedQuantity ?? this.creditedQuantity,
lastUpdatedAt: lastUpdatedAt ?? this.lastUpdatedAt,
heldReason: heldReason ?? this.heldReason,
);
}
}

View File

@ -0,0 +1,29 @@
/// Eingabetyp eines Service. `boolean` → Checkbox, `numeric` → Zahlenfeld
/// mit optionalen Grenzen.
enum ServiceKind { boolean, numeric }
/// Service-Stammdatensatz (früher „Lieferoption") — admin-konfigurierbar.
/// In Phase 4 rendert die App aus den aktiven Services die Auswahl.
class Service {
const Service({
required this.id,
required this.key,
required this.name,
required this.kind,
required this.active,
required this.sortOrder,
this.minValue,
this.maxValue,
});
final String id;
final String key;
final String name;
final ServiceKind kind;
final bool active;
final int sortOrder;
/// Nur bei [ServiceKind.numeric] relevant.
final int? minValue;
final int? maxValue;
}

View File

@ -0,0 +1,53 @@
/// Aggregat-Wurzel eines Tour-Tages.
///
/// Die `Tour` selbst ist minimal — sie hält nur Identität und Eckdaten;
/// die fachlich interessanten Daten (Lieferungen + Stammdaten-Lookups)
/// sitzen in `TourDetails`. Diese Trennung erlaubt es, Touren-Listen
/// (z. B. `/me/tours/today`) zu rendern, ohne das gesamte Aggregat
/// laden zu müssen.
class Tour {
const Tour({
required this.id,
required this.accountId,
required this.date,
required this.syncedAt,
});
final String id;
final int accountId;
final DateTime date;
/// Zeitpunkt des letzten ERP-Sync. Wird in der Header-Zeile als
/// „Stand: …"-Hinweis angezeigt — wenn das ungewöhnlich alt ist, sieht
/// der Fahrer das.
final DateTime syncedAt;
Tour copyWith({
String? id,
int? accountId,
DateTime? date,
DateTime? syncedAt,
}) {
return Tour(
id: id ?? this.id,
accountId: accountId ?? this.accountId,
date: date ?? this.date,
syncedAt: syncedAt ?? this.syncedAt,
);
}
}
/// Tagestour-Übersicht, wie sie `/me/tours/today` liefert. Schlankes Objekt
/// für die Initialphase (Tour-Auswahl), ohne das volle Aggregat zu
/// transportieren.
class TourSummary {
const TourSummary({
required this.tourId,
required this.tourDate,
required this.deliveryCount,
});
final String tourId;
final DateTime tourDate;
final int deliveryCount;
}

View File

@ -0,0 +1,428 @@
import 'article.dart';
import 'contact_source.dart';
import 'customer.dart';
import 'delivery.dart';
import 'delivery_credit.dart';
import 'delivery_item.dart';
import 'delivery_note.dart';
import 'delivery_service_value.dart';
import 'service.dart';
import 'tour.dart';
import 'warehouse.dart';
/// Voll geladenes Tour-Aggregat. Enthält die Tour selbst, alle Lieferungen
/// inkl. Items sowie *alle* Stammdaten, die von diesem Schnitt referenziert
/// werden. Die Stammdaten kommen als Lookup-Maps statt als List, damit das
/// UI ohne O(n)-Suchen auskommt.
///
/// Die Notizen sind im Backend in einer flachen Liste — wir indizieren sie
/// hier einmal per `deliveryId`, weil das UI sie immer „pro Lieferung"
/// braucht.
class TourDetails {
TourDetails({
required this.tour,
required this.deliveries,
required this.customers,
required this.contacts,
required this.articles,
required this.warehouses,
required this.notesByDeliveryId,
required this.creditsByDeliveryId,
required this.services,
required this.serviceValuesByDeliveryId,
required this.contactSourcesByDeliveryId,
required this.contactChannelsBySourceId,
});
final Tour tour;
/// Alle Lieferungen dieser Tour. Reihenfolge: unsortiert; UI ruft
/// `deliveriesSorted` auf, wenn Sortier-Reihenfolge benötigt wird.
final List<Delivery> deliveries;
// ─── Stammdaten-Lookups (Id → Entity) ─────────────────────────────────
final Map<String, Customer> customers;
final Map<String, CustomerContact> contacts;
final Map<String, Article> articles;
final Map<String, Warehouse> warehouses;
/// Pro Lieferung: alle Notizen, aufsteigend nach `createdAt`. Wenn eine
/// Lieferung keine Notizen hat, liefert der Lookup `null` zurück — das
/// UI muss das berücksichtigen.
final Map<String, List<DeliveryNote>> notesByDeliveryId;
/// Pro Lieferung die aktuelle Betrags-Gutschrift (höchstens eine). Fehlt
/// der Eintrag, gibt es aktuell keine Gutschrift.
final Map<String, DeliveryCredit> creditsByDeliveryId;
/// Aktive Service-Definitionen (Stammdaten), nach `sortOrder`. Daraus
/// rendert Phase 4 die Auswahl.
final List<Service> services;
/// Pro Lieferung die gesetzten Service-Werte, indiziert per `serviceId`.
final Map<String, Map<String, DeliveryServiceValue>> serviceValuesByDeliveryId;
/// Pro Lieferung die Adress-Quellen aus dem ERP (Belegadresse / Liefer-
/// adresse / Rechnungsadresse / Ansprechpartner / Kundenstamm). Wird vom
/// Sync gefüllt; leere Quellen kommen nicht durch — wer hier 0 Einträge
/// sieht, hat im ERP keinen einzigen Kontakt am Beleg hängen.
final Map<String, List<ContactSource>> contactSourcesByDeliveryId;
/// Pro Quelle alle ihre Kommunikationskanäle. Reihenfolge folgt der
/// ERP-Position (Telefon 1 → Position 1, Telefon 2 → Position 2, …),
/// das UI kann die Liste direkt rendern.
final Map<String, List<ContactChannel>> contactChannelsBySourceId;
// ─── Convenience für UI ───────────────────────────────────────────────
/// Lieferungen sortiert nach `sortOrder` aufsteigend. Falls zwei
/// Lieferungen identische Werte tragen (sollte nicht vorkommen, dient
/// nur als Defensive), fällt der Vergleich auf die Belegnummer zurück.
List<Delivery> get deliveriesSorted {
final copy = List<Delivery>.of(deliveries);
copy.sort((a, b) {
final byOrder = a.sortOrder.compareTo(b.sortOrder);
if (byOrder != 0) return byOrder;
return a.erpBelegnummer.compareTo(b.erpBelegnummer);
});
return copy;
}
Customer? customerOf(Delivery delivery) => customers[delivery.customerId];
Iterable<CustomerContact> contactsOf(Delivery delivery) sync* {
for (final id in delivery.contactPersonIds) {
final c = contacts[id];
if (c != null) yield c;
}
}
/// Alle Adress-Quellen einer Lieferung — in der vom Backend gelieferten
/// Reihenfolge (nach [ContactRole], anschließend nach Quell-Id für
/// stabile UI). Leere Liste, wenn diese Lieferung im ERP keinen Kontakt
/// hängen hat.
List<ContactSource> contactSourcesOf(Delivery delivery) =>
contactSourcesByDeliveryId[delivery.id] ?? const <ContactSource>[];
/// Alle Kanäle einer einzelnen Quelle. Leere Liste, wenn die Quelle nur
/// einen Namensblock trägt (z. B. ein Ansprechpartner ohne Telefonnummer).
List<ContactChannel> channelsOf(ContactSource source) =>
contactChannelsBySourceId[source.id] ?? const <ContactChannel>[];
/// Wie [contactSourcesOf], aber Quellen mit identischem Namensblock UND
/// identischer Channel-Liste sind zu einem [MergedContactSource] mit
/// Multi-Rollen-Header zusammengeführt. Das eliminiert die typische
/// Doppelung „Belegadresse + Kundenstamm" bei Belegen, deren
/// `Belegkopf.AdressId` ohnehin auf die Kunden-Stammadresse zeigt.
///
/// Identity-Fingerprint: alle Namensfelder (Anrede / Titel / Name1..3 /
/// Abteilung / Funktion) plus die nach (kind, position) sortierten
/// (kind, value)-Paare. Zwei Quellen mit identischem Namen, aber
/// abweichenden Channels werden NICHT gemerged — das wäre fachlich
/// falsch (zwei verschiedene Kontaktdatensätze derselben Person).
List<MergedContactSource> mergedContactSourcesOf(Delivery delivery) {
final sources = contactSourcesOf(delivery);
if (sources.isEmpty) return const <MergedContactSource>[];
// Reihenfolge der Erstauftritte merken — die Backend-Sortierung
// (Quellen nach Rolle aufsteigend) bestimmt damit auch die Reihenfolge
// der Merge-Gruppen in der UI.
final order = <String>[];
final byKey = <String, List<ContactSource>>{};
for (final s in sources) {
final key = _identityKey(s, channelsOf(s));
if (!byKey.containsKey(key)) {
order.add(key);
byKey[key] = <ContactSource>[];
}
byKey[key]!.add(s);
}
return [
for (final key in order) _buildMerged(byKey[key]!),
];
}
/// Fingerprint einer Quelle: Namensblock + alle (kind, position, value)-
/// Tripel. Vorab nach (kind-Index, position) sortiert, damit semantisch
/// gleiche Quellen unabhängig von der Speicher-Reihenfolge denselben
/// Schlüssel bekommen.
String _identityKey(ContactSource s, List<ContactChannel> channels) {
final namePart = [
s.anrede ?? '',
s.titel ?? '',
s.name1 ?? '',
s.name2 ?? '',
s.name3 ?? '',
s.abteilung ?? '',
s.funktion ?? '',
].join('|');
final sortedChannels = List<ContactChannel>.of(channels)
..sort((a, b) {
final byKind = a.kind.index.compareTo(b.kind.index);
if (byKind != 0) return byKind;
return a.position.compareTo(b.position);
});
final channelPart = sortedChannels
.map((c) => '${c.kind.name}:${c.position}:${c.value}')
.join('|');
return '$namePart||$channelPart';
}
MergedContactSource _buildMerged(List<ContactSource> group) {
// Namensblock + Channels von der ersten Quelle übernehmen — alle Quellen
// in der Gruppe sind per Identity-Key garantiert deckungsgleich.
final first = group.first;
final roles = group.map((s) => s.role).toList()
..sort((a, b) => a.index.compareTo(b.index));
return MergedContactSource(
roles: roles,
anrede: first.anrede,
titel: first.titel,
name1: first.name1,
name2: first.name2,
name3: first.name3,
abteilung: first.abteilung,
funktion: first.funktion,
channels: channelsOf(first),
);
}
Article? articleOf(String articleId) => articles[articleId];
Warehouse? warehouseOf(String warehouseId) => warehouses[warehouseId];
List<DeliveryNote> notesOf(String deliveryId) =>
notesByDeliveryId[deliveryId] ?? const <DeliveryNote>[];
/// Aktuelle Betrags-Gutschrift dieser Lieferung, oder `null`.
DeliveryCredit? creditOf(String deliveryId) =>
creditsByDeliveryId[deliveryId];
/// Gesetzter Service-Wert dieser Lieferung für einen Service, oder `null`.
DeliveryServiceValue? serviceValueOf(String deliveryId, String serviceId) =>
serviceValuesByDeliveryId[deliveryId]?[serviceId];
/// Alle Attachment-IDs, die von Foto-Notizen dieser Tour referenziert
/// werden — die Menge der „noch gültigen" Bilder. Dient dem Cache-Pruning
/// (`AttachmentCache.retainOnly`): gecachte Vorschauen zu IDs, die hier
/// nicht (mehr) vorkommen, gehören zu gelöschten Notizen und dürfen weg.
Set<String> get referencedAttachmentIds {
final ids = <String>{};
for (final notes in notesByDeliveryId.values) {
for (final n in notes) {
final attachment = n.imageAttachment;
if (attachment != null) ids.add(attachment);
}
}
return ids;
}
bool isArticleScannable(String articleId) =>
articles[articleId]?.scannable ?? false;
/// Nicht-scanbare Positionen einer Lieferung (Dienstleistung / Pauschale /
/// Fracht — `article.scannable == false`). Entfernte Zeilen sind hier
/// ausgefiltert, weil eine entfernte Dienstleistung den Belade-/Anfahrt-
/// Hinweis nicht mehr rechtfertigt.
///
/// Diese Positionen werden in der Beladen-Phase **nicht gescannt**, sind
/// aber fachlich der Grund, warum eine Lieferung ohne scanbare Ware (reine
/// Dienstleistung) trotzdem angefahren werden muss.
Iterable<DeliveryItem> nonScannableItems(Delivery delivery) sync* {
for (final it in delivery.items) {
if (it.isRemoved) continue;
if (isArticleScannable(it.articleId)) continue;
yield it;
}
}
/// `true`, wenn die Lieferung mindestens eine nicht-scanbare Position
/// (Dienstleistung / Pauschale) trägt — Basis für den Dienstleistungs-
/// Hinweis in der Beladen-Ansicht.
bool hasServiceItems(Delivery delivery) =>
nonScannableItems(delivery).isNotEmpty;
// ─── Lager-Aufteilung in der Beladen-Phase ───────────────────────────
//
// Der Fahrer startet standardmäßig im Standardlager (`Warehouse.isStandard`).
// Filialen werden separat angefahren — sie blockieren NICHT den Übergang
// in die Auslieferungs-Phase. Eine Lieferung gilt deshalb als „fertig
// beladen", sobald **alle scanbaren Standardlager-Items** durch sind;
// Filial-Items werden in der UI sichtbar gekennzeichnet, damit der
// Fahrer weiß, dass er noch eine zweite Station ansteuern muss.
bool _isStandard(String warehouseId) =>
warehouseOf(warehouseId)?.isStandard ?? false;
bool _isExternal(String warehouseId) {
final w = warehouseOf(warehouseId);
return w != null && !w.isStandard;
}
/// Iterator über die scanbaren Items einer Lieferung. `includeRemoved`
/// kontrolliert, ob entfernte Positionen Teil der Iteration sind:
///
/// * `false` (default) — für Status-Berechnungen (`standardWarehouseLoadingDone`,
/// `hasExternalWarehouseItems`, …). Entfernte Positionen blockieren
/// sonst „Fertig"-Marker oder triggern fälschlich Filial-Hinweise.
/// * `true` — für die UI-Anzeige (`itemsGroupedByWarehouse`), damit der
/// Fahrer entfernte Items als durchgestrichene Zeilen weiterhin sieht
/// und sie ggf. wiederherstellen kann.
Iterable<DeliveryItem> _activeScannableItems(
Delivery delivery, {
bool includeRemoved = false,
}) sync* {
for (final it in delivery.items) {
if (!includeRemoved && it.isRemoved) continue;
if (!isArticleScannable(it.articleId)) continue;
yield it;
}
}
/// Standardlager-Beladung dieser Lieferung ist erledigt: jedes scanbare,
/// nicht-entfernte Item aus dem Standardlager ist `done`. Lieferungen
/// ohne Standardlager-Items (= alles Filiale) sind trivial fertig —
/// im Standardlager ist dann nichts zu tun.
bool standardWarehouseLoadingDone(Delivery delivery) {
return _activeScannableItems(delivery)
.where((it) => _isStandard(it.warehouseId))
.every((it) => it.isDone);
}
/// Lieferung enthält mindestens ein noch relevantes Filial-Item.
/// „Relevant" = scanbar + nicht entfernt; ob das Item schon gescannt ist
/// oder nicht spielt für diese Markierung keine Rolle (entscheidend ist
/// nur, dass der Fahrer ein zusätzliches Lager anfahren muss).
bool hasExternalWarehouseItems(Delivery delivery) {
return _activeScannableItems(delivery).any(
(it) => _isExternal(it.warehouseId),
);
}
/// Filial-Items, die noch nicht beladen wurden — gedacht für die
/// Auslieferungs-Übersicht: dort soll der Fahrer auf einen Blick sehen,
/// dass er *vor* der Anfahrt zum Kunden noch ein zweites Lager ansteuern
/// muss, und welche Artikel ihn dort erwarten.
///
/// Item-Filter: scanbar + nicht entfernt + Filiale + `!isDone`. Items
/// mit Status `held` zählen ebenfalls als „nicht geholt", weil das
/// Warenholen noch aussteht.
///
/// Sortierung: Lager alphabetisch, innerhalb des Lagers nach
/// `belegzeilenNr` aufsteigend — stabile Reihenfolge zwischen Builds.
List<({Warehouse warehouse, List<DeliveryItem> items})>
pendingExternalWarehouseGroups(Delivery delivery) {
final byWarehouseId = <String, List<DeliveryItem>>{};
for (final it in _activeScannableItems(delivery)) {
if (!_isExternal(it.warehouseId)) continue;
if (it.isDone) continue;
byWarehouseId.putIfAbsent(it.warehouseId, () => []).add(it);
}
final groups = <({Warehouse warehouse, List<DeliveryItem> items})>[];
byWarehouseId.forEach((warehouseId, items) {
final w = warehouseOf(warehouseId);
if (w == null) return;
items.sort((a, b) => a.belegzeilenNr.compareTo(b.belegzeilenNr));
groups.add((warehouse: w, items: items));
});
groups.sort((a, b) => a.warehouse.name.compareTo(b.warehouse.name));
return groups;
}
/// `true`, wenn die Lieferung noch mindestens einen offenen
/// Filial-Artikel hat (= Fahrer muss zuerst in die Filiale).
bool hasPendingExternalWarehouseItems(Delivery delivery) {
for (final it in _activeScannableItems(delivery)) {
if (!_isExternal(it.warehouseId)) continue;
if (!it.isDone) return true;
}
return false;
}
/// Eindeutige Filial-Namen dieser Lieferung — für Badges /
/// Sektions-Header in der UI. Sortiert nach Lager-Name, damit die
/// Reihenfolge stabil bleibt zwischen Builds.
List<String> externalWarehouseLabels(Delivery delivery) {
final names = <String>{};
for (final it in _activeScannableItems(delivery)) {
if (!_isExternal(it.warehouseId)) continue;
final w = warehouseOf(it.warehouseId);
if (w != null) names.add(w.name);
}
final list = names.toList()..sort();
return list;
}
/// Gruppiert die scanbaren Items einer Lieferung nach Warehouse-Id —
/// Standardlager-Eintrag (sofern vorhanden) immer zuerst, danach
/// Filiale alphabetisch nach Lager-Name. Items innerhalb einer
/// Gruppe sind nach `belegzeilenNr` aufsteigend sortiert.
List<({Warehouse warehouse, List<DeliveryItem> items})>
itemsGroupedByWarehouse(Delivery delivery) {
final byWarehouseId = <String, List<DeliveryItem>>{};
// Entfernte Items bleiben in der UI sichtbar (durchgestrichen) und
// können dort über das Aktions-Menü wiederhergestellt werden — der
// Status-Pfad (`standardWarehouseLoadingDone` etc.) ignoriert sie
// trotzdem, weil die jeweiligen Helper ohne `includeRemoved` laufen.
for (final it in _activeScannableItems(delivery, includeRemoved: true)) {
byWarehouseId.putIfAbsent(it.warehouseId, () => []).add(it);
}
for (final list in byWarehouseId.values) {
list.sort((a, b) => a.belegzeilenNr.compareTo(b.belegzeilenNr));
}
// In zwei Buckets aufteilen, damit der Aufrufer Standard zuerst sieht.
final standard = <({Warehouse warehouse, List<DeliveryItem> items})>[];
final external = <({Warehouse warehouse, List<DeliveryItem> items})>[];
byWarehouseId.forEach((warehouseId, items) {
final w = warehouseOf(warehouseId);
if (w == null) return; // Defensive: defekte Stammdaten ignorieren
final group = (warehouse: w, items: items);
if (w.isStandard) {
standard.add(group);
} else {
external.add(group);
}
});
external.sort((a, b) => a.warehouse.name.compareTo(b.warehouse.name));
return [...standard, ...external];
}
/// Neues Aggregat mit ausgetauschten/erweiterten Listen — gedacht für
/// Bloc-Reducer (Reorder, Assign-Car etc.), die das ganze Aggregat
/// behalten und nur ein paar Lieferungen austauschen wollen.
TourDetails copyWith({
Tour? tour,
List<Delivery>? deliveries,
Map<String, List<DeliveryNote>>? notesByDeliveryId,
Map<String, DeliveryCredit>? creditsByDeliveryId,
Map<String, Map<String, DeliveryServiceValue>>? serviceValuesByDeliveryId,
}) {
return TourDetails(
tour: tour ?? this.tour,
deliveries: deliveries ?? this.deliveries,
customers: customers,
contacts: contacts,
articles: articles,
warehouses: warehouses,
notesByDeliveryId: notesByDeliveryId ?? this.notesByDeliveryId,
creditsByDeliveryId: creditsByDeliveryId ?? this.creditsByDeliveryId,
services: services,
serviceValuesByDeliveryId:
serviceValuesByDeliveryId ?? this.serviceValuesByDeliveryId,
contactSourcesByDeliveryId: contactSourcesByDeliveryId,
contactChannelsBySourceId: contactChannelsBySourceId,
);
}
/// Ersetzt eine einzelne Lieferung im Aggregat. Reihenfolge bleibt erhalten.
TourDetails replaceDelivery(Delivery updated) {
final next = List<Delivery>.of(deliveries);
final idx = next.indexWhere((d) => d.id == updated.id);
if (idx == -1) return this;
next[idx] = updated;
return copyWith(deliveries: next);
}
}

View File

@ -0,0 +1,32 @@
/// Lager-Standort, von dem ein `DeliveryItem` geladen wird.
///
/// `isStandard` markiert das Hauptlager — die App nutzt das, um in der
/// Loading-Übersicht ein „Sonderlager"-Banner zu zeigen, sobald Items aus
/// einem nicht-Standard-Lager kommen.
class Warehouse {
const Warehouse({
required this.id,
required this.name,
required this.code,
required this.isStandard,
});
final String id;
final String name;
final String code;
final bool isStandard;
Warehouse copyWith({
String? id,
String? name,
String? code,
bool? isStandard,
}) {
return Warehouse(
id: id ?? this.id,
name: name ?? this.name,
code: code ?? this.code,
isStandard: isStandard ?? this.isStandard,
);
}
}

View File

@ -0,0 +1,36 @@
import 'package:hl_lieferservice/domain/entity/payment_method.dart';
/// Port für Zahlungsmethoden — globale Stammdaten.
///
/// Im Gegensatz zu `CarsRepository` keine Account-Filter: die Methoden
/// sind firmenweit, alle Fahrer sehen dieselbe Liste.
///
/// Lösch-Verhalten: `delete` wirft eine `PaymentMethodsRepositoryException`
/// mit konkretem `409`-Fall, wenn die Methode noch von Lieferungen
/// referenziert wird (Backend hat dafür den FK-RESTRICT). Für „weiches
/// Entfernen" gibt es `update(active: false)`.
abstract interface class PaymentMethodsRepository {
Future<List<PaymentMethod>> list({bool includeInactive = false});
Future<PaymentMethod> create({
required String code,
required String name,
});
Future<PaymentMethod> update({
required String id,
String? name,
bool? active,
});
Future<void> delete(String id);
}
class PaymentMethodsRepositoryException implements Exception {
const PaymentMethodsRepositoryException(this.message, [this.cause]);
final String message;
final Object? cause;
@override
String toString() => 'PaymentMethodsRepositoryException: $message';
}

View File

@ -0,0 +1,210 @@
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_credit.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/tour.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
/// Port für das Tour-Aggregat.
///
/// Der Port deckt in dieser Migrations-Phase nur die Read-Seite + die
/// beiden Operationen, die zum Loading-Flow zwingend gebraucht werden:
/// Sortierung und Fahrzeug-Zuweisung. Hold/Resume/Cancel/Complete und
/// Notizen werden in C+D-4 nachgezogen, damit das hier nicht überladen
/// wird und der Bloc fokussiert bleibt.
///
/// Account-Filter sitzt serverseitig im JWT — der Client schickt nie eine
/// `personalnummer`/`accountId` mit.
abstract interface class TourRepository {
/// Die heutige Tour-Übersicht des angemeldeten Fahrers oder `null`,
/// wenn keine Tour für heute angelegt ist (ERP-Sync noch nicht
/// gelaufen, Treiber-Urlaub etc.).
///
/// Liefert nur die schlanke `TourSummary`-Repräsentation;
/// [getTourDetails] zieht dann den vollen Aggregat-Snapshot.
Future<TourSummary?> getMyTourSummaryOfToday();
/// Lädt das volle Tour-Aggregat (Tour + Lieferungen + Items +
/// Stammdaten + Notizen) für die gegebene Tour-Id.
Future<TourDetails> getTourDetails(String tourId);
/// Convenience: kombiniert [getMyTourSummaryOfToday] + [getTourDetails]
/// und gibt `null` zurück, wenn keine Tour existiert. Verwendet die App
/// beim Initial-Load.
Future<TourDetails?> getMyTourDetailsOfToday();
/// Schreibt die Sortier-Reihenfolge der Lieferungen einer Tour neu.
///
/// `orderedDeliveryIds` muss **alle** Lieferungen der Tour enthalten,
/// in der gewünschten Reihenfolge — das Backend lehnt unvollständige
/// Listen mit `400 validation` ab.
///
/// Rückgabe: deliveryId → neuer sortOrder (für den Bloc-Reducer).
Future<Map<String, int>> setDeliveryOrder({
required String tourId,
required List<String> orderedDeliveryIds,
});
/// Weist einer Lieferung ein Fahrzeug zu. `carId == null` löst die
/// bestehende Zuweisung. Der Server gibt die aktualisierte Delivery
/// zurück; weil dieser Endpoint nur Stamm-Felder mutiert, ist es Aufgabe
/// des Aufrufers, die `items` aus dem lokalen Aggregat zu erhalten.
///
/// Rückgabe: die Stamm-Delivery **ohne** Items — Aufrufer nutzt
/// `copyWith(items: ...)` zum Mergen mit dem lokalen State.
Future<Delivery> assignCarToDelivery({
required String deliveryId,
required String? carId,
});
/// Bricht eine Lieferung ab — endgültig (`canceled`). `reason` ist
/// vom Backend Pflicht; leere Begründungen werden mit 400 abgelehnt.
/// Rückgabe: Server-Snapshot der Delivery **ohne** Items.
Future<Delivery> cancelDelivery({
required String deliveryId,
required String reason,
});
/// Pausiert eine Lieferung (`held`). Reversibel über [resumeDelivery].
/// `reason` ist Pflicht.
Future<Delivery> holdDelivery({
required String deliveryId,
required String reason,
});
/// Setzt eine pausierte Lieferung auf `active` zurück. Kein Reason
/// erforderlich.
Future<Delivery> resumeDelivery({required String deliveryId});
/// Schließt eine Lieferung ab (`completed`). Lädt beide Unterschriften
/// (Kunde + Fahrer, PNG) per multipart hoch und dokumentiert die
/// Bestätigungen des Kunden. Atomar serverseitig — das Backend prüft
/// vorher: Lieferung aktiv, alle scanbaren Positionen fertig, Notizen
/// bestätigt (falls vorhanden). [paymentMethodId] persistiert die ggf. im
/// Summary geänderte Zahlungsmethode (muss existieren + aktiv sein); `null`
/// lässt die am Beleg hinterlegte Methode unangetastet. Rückgabe:
/// Server-Snapshot der Delivery **ohne** Items (Aufrufer merged Items aus
/// dem lokalen Aggregat).
Future<Delivery> completeDelivery({
required String deliveryId,
required List<int> customerSignaturePng,
required List<int> driverSignaturePng,
required bool receiptConfirmed,
required bool notesAcknowledged,
required List<String> acknowledgedNoteIds,
String? paymentMethodId,
String? actorCarId,
bool paymentCollected = false,
});
/// Legt eine neue Notiz an einer Lieferung an.
///
/// Mindestens eines von [text] und [imageAttachment] muss inhaltlich
/// gefüllt sein — das Backend erzwingt das. Aktuell unterstützt die App
/// nur den Text-Pfad; das `imageAttachment`-Feld bleibt der zukünftigen
/// Foto-Upload-Phase vorbehalten.
///
/// Rückgabe: die neu angelegte Notiz (mit Server-gesetzter `id` und
/// `createdAt`) — der Aufrufer hängt sie an das lokale Tour-Aggregat.
Future<DeliveryNote> addDeliveryNote({
required String deliveryId,
String? text,
String? imageAttachment,
String? creditDeliveryItemId,
bool isAmountCreditNote,
});
/// Ändert Text/Bild einer bestehenden Notiz. Mindestens eines von [text]
/// und [imageAttachment] muss inhaltlich gefüllt sein. Rückgabe: die
/// aktualisierte Notiz (Autor/`createdAt` bleiben).
Future<DeliveryNote> updateDeliveryNote({
required String deliveryId,
required String noteId,
String? text,
String? imageAttachment,
});
/// Löscht eine Notiz. Innerhalb des (geteilten) Accounts darf jeder Fahrer
/// löschen — keine Autor-Prüfung serverseitig.
Future<void> deleteDeliveryNote({
required String deliveryId,
required String noteId,
});
/// Lädt ein Bild zu einer Lieferung hoch (multipart, Feld `file`). Das
/// Backend reicht es an DOCUframe weiter und legt eine Bild-Notiz mit der
/// zurückgelieferten Referenz (`~ObjectID`) als `imageAttachment` an.
/// Rückgabe: die neue Notiz.
Future<DeliveryNote> uploadDeliveryNoteImage({
required String deliveryId,
required String filename,
required String mime,
required List<int> bytes,
});
/// Setzt/ändert die Betrags-Gutschrift einer Lieferung. Append-only +
/// idempotent über [clientEventId]. Rückgabe: aktueller Stand (`null`, wenn
/// — theoretisch — nichts gesetzt ist).
Future<DeliveryCredit?> setDeliveryCredit({
required String deliveryId,
required String clientEventId,
required int amountCents,
required String reason,
String? actorCarId,
});
/// Entfernt die Betrags-Gutschrift einer Lieferung (append-only `remove`).
/// Rückgabe: aktueller Stand danach (`null`).
Future<DeliveryCredit?> removeDeliveryCredit({
required String deliveryId,
required String clientEventId,
String? actorCarId,
});
/// Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum
/// Service-Typ passende Feld angeben. Rückgabe: der gespeicherte Wert.
Future<DeliveryServiceValue> setDeliveryService({
required String deliveryId,
required String serviceId,
bool? boolValue,
int? numericValue,
String? actorCarId,
});
/// Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt").
Future<void> removeDeliveryService({
required String deliveryId,
required String serviceId,
});
/// Wendet eine Liste Scan-Ereignisse als Batch am Server an.
///
/// Der Endpoint ist bewusst Bulk: damit kann der Client einen
/// Scanner-Burst (z. B. 5 Barcodes in 2 Sekunden) in einem HTTP-Call
/// abschicken, **muss** aber nicht — auch ein Aufruf mit nur einem
/// `ScanIntent` ist erlaubt.
///
/// Idempotenz: das Backend speichert pro `clientScanId` einmal. Wer
/// retried, bekommt `duplicate` zurück; doppelte Anwendung kann es
/// nicht geben.
///
/// Rückgabe: pro Eingabe-Intent ein [ScanOutcome] (Key =
/// `clientScanId`). Die Map enthält **jeden** Intent, auch
/// `rejected`-Fälle; bei Netzwerk-/Server-Fehlern wirft das Repository
/// stattdessen [TourRepositoryException], die Map ist dann nicht
/// teilweise gefüllt.
Future<Map<String, ScanOutcome>> applyScans(List<ScanIntent> intents);
}
/// Allgemeine Repository-Exception für Tour-Operationen. Konkrete Impls
/// dürfen spezifischere Subtypen werfen.
class TourRepositoryException implements Exception {
const TourRepositoryException(this.message, [this.cause]);
final String message;
final Object? cause;
@override
String toString() => 'TourRepositoryException: $message';
}

View File

@ -1,20 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'address.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class AddressDTO {
AddressDTO(
{required this.streetName,
required this.postalCode,
required this.city});
String streetName;
String postalCode;
String city;
factory AddressDTO.fromJson(Map<String, dynamic> json) =>
_$AddressDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$AddressDTOToJson(this);
}

View File

@ -1,20 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'address.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AddressDTO _$AddressDTOFromJson(Map<String, dynamic> json) => AddressDTO(
streetName: json['street_name'] as String,
postalCode: json['postal_code'] as String,
city: json['city'] as String,
);
Map<String, dynamic> _$AddressDTOToJson(AddressDTO instance) =>
<String, dynamic>{
'street_name': instance.streetName,
'postal_code': instance.postalCode,
'city': instance.city,
};

View File

@ -1,45 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import 'component.dart';
part 'article.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class ArticleDTO {
ArticleDTO({
required this.name,
required this.articleNr,
required this.quantity,
required this.price,
required this.scannable,
required this.internalId,
required this.scannedRemovedAmount,
required this.scannedAmount,
required this.removeNoteId,
required this.taxRate,
required this.isParent,
this.components,
this.warehouseNr,
this.warehouseName,
});
String name;
String articleNr;
String quantity;
String price;
String taxRate;
String internalId;
String scannedAmount;
String scannedRemovedAmount;
String? removeNoteId;
bool scannable;
bool isParent;
List<ComponentDTO>? components;
String? warehouseNr;
String? warehouseName;
factory ArticleDTO.fromJson(Map<String, dynamic> json) =>
_$ArticleDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$ArticleDTOToJson(this);
}

View File

@ -1,45 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'article.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ArticleDTO _$ArticleDTOFromJson(Map<String, dynamic> json) => ArticleDTO(
name: json['name'] as String,
articleNr: json['article_nr'] as String,
quantity: json['quantity'] as String,
price: json['price'] as String,
scannable: json['scannable'] as bool,
internalId: json['internal_id'] as String,
scannedRemovedAmount: json['scanned_removed_amount'] as String,
scannedAmount: json['scanned_amount'] as String,
removeNoteId: json['remove_note_id'] as String?,
taxRate: json['tax_rate'] as String,
isParent: json['is_parent'] as bool,
components:
(json['components'] as List<dynamic>?)
?.map((e) => ComponentDTO.fromJson(e as Map<String, dynamic>))
.toList(),
warehouseNr: json['warehouse_nr'] as String?,
warehouseName: json['warehouse_name'] as String?,
);
Map<String, dynamic> _$ArticleDTOToJson(ArticleDTO instance) =>
<String, dynamic>{
'name': instance.name,
'article_nr': instance.articleNr,
'quantity': instance.quantity,
'price': instance.price,
'tax_rate': instance.taxRate,
'internal_id': instance.internalId,
'scanned_amount': instance.scannedAmount,
'scanned_removed_amount': instance.scannedRemovedAmount,
'remove_note_id': instance.removeNoteId,
'scannable': instance.scannable,
'is_parent': instance.isParent,
'components': instance.components,
'warehouse_nr': instance.warehouseNr,
'warehouse_name': instance.warehouseName,
};

View File

@ -1,16 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'basic_response.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class BasicResponseDTO {
BasicResponseDTO(
{required this.succeeded,
required this.message});
final bool succeeded;
final String message;
factory BasicResponseDTO.fromJson(Map<String, dynamic> json) => _$BasicResponseDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$BasicResponseDTOToJson(this);
}

View File

@ -1,19 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'basic_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BasicResponseDTO _$BasicResponseDTOFromJson(Map<String, dynamic> json) =>
BasicResponseDTO(
succeeded: json['succeeded'] as bool,
message: json['message'] as String,
);
Map<String, dynamic> _$BasicResponseDTOToJson(BasicResponseDTO instance) =>
<String, dynamic>{
'succeeded': instance.succeeded,
'message': instance.message,
};

View File

@ -1,16 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'car.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class CarDTO {
CarDTO(
{required this.id,
required this.plate});
final String id;
final String plate;
factory CarDTO.fromJson(Map<String, dynamic> json) => _$CarDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$CarDTOToJson(this);
}

View File

@ -1,15 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'car.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CarDTO _$CarDTOFromJson(Map<String, dynamic> json) =>
CarDTO(id: json['id'] as String, plate: json['plate'] as String);
Map<String, dynamic> _$CarDTOToJson(CarDTO instance) => <String, dynamic>{
'id': instance.id,
'plate': instance.plate,
};

View File

@ -1,20 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'car_add.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class CarAddDTO {
CarAddDTO(
{required this.teamId,
required this.plate});
final int teamId;
final String plate;
factory CarAddDTO.fromJson(Map<String, dynamic> json) => _$CarAddDTOFromJson(json);
factory CarAddDTO.make(int teamID, String plate) {
Map<String, dynamic> data = {"team_id": teamID, "plate": plate};
return CarAddDTO.fromJson(data);
}
Map<dynamic, dynamic> toJson() => _$CarAddDTOToJson(this);
}

View File

@ -1,17 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'car_add.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CarAddDTO _$CarAddDTOFromJson(Map<String, dynamic> json) => CarAddDTO(
teamId: (json['team_id'] as num).toInt(),
plate: json['plate'] as String,
);
Map<String, dynamic> _$CarAddDTOToJson(CarAddDTO instance) => <String, dynamic>{
'team_id': instance.teamId,
'plate': instance.plate,
};

View File

@ -1,19 +0,0 @@
import 'car.dart';
import 'package:json_annotation/json_annotation.dart';
part 'car_add_response.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class CarAddResponseDTO {
CarAddResponseDTO(
{required this.succeeded,
required this.message,
required this.car});
final bool succeeded;
final String message;
final CarDTO car;
factory CarAddResponseDTO.fromJson(Map<String, dynamic> json) => _$CarAddResponseDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$CarAddResponseDTOToJson(this);
}

View File

@ -1,21 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'car_add_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CarAddResponseDTO _$CarAddResponseDTOFromJson(Map<String, dynamic> json) =>
CarAddResponseDTO(
succeeded: json['succeeded'] as bool,
message: json['message'] as String,
car: CarDTO.fromJson(json['car'] as Map<String, dynamic>),
);
Map<String, dynamic> _$CarAddResponseDTOToJson(CarAddResponseDTO instance) =>
<String, dynamic>{
'succeeded': instance.succeeded,
'message': instance.message,
'car': instance.car,
};

View File

@ -1,19 +0,0 @@
import 'car.dart';
import 'package:json_annotation/json_annotation.dart';
part 'car_get_response.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class CarGetResponseDTO {
CarGetResponseDTO(
{required this.succeeded,
required this.message,
required this.cars});
final bool succeeded;
final String message;
final List<CarDTO>? cars;
factory CarGetResponseDTO.fromJson(Map<String, dynamic> json) => _$CarGetResponseDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$CarGetResponseDTOToJson(this);
}

View File

@ -1,24 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'car_get_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CarGetResponseDTO _$CarGetResponseDTOFromJson(Map<String, dynamic> json) =>
CarGetResponseDTO(
succeeded: json['succeeded'] as bool,
message: json['message'] as String,
cars:
(json['cars'] as List<dynamic>?)
?.map((e) => CarDTO.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$CarGetResponseDTOToJson(CarGetResponseDTO instance) =>
<String, dynamic>{
'succeeded': instance.succeeded,
'message': instance.message,
'cars': instance.cars,
};

View File

@ -1,23 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'component.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class ComponentDTO {
ComponentDTO({
required this.articleNr,
required this.name,
required this.quantity,
required this.pos,
});
String articleNr;
String name;
String quantity;
String pos;
factory ComponentDTO.fromJson(Map<String, dynamic> json) =>
_$ComponentDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$ComponentDTOToJson(this);
}

View File

@ -1,22 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'component.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ComponentDTO _$ComponentDTOFromJson(Map<String, dynamic> json) => ComponentDTO(
articleNr: json['article_nr'] as String,
name: json['name'] as String,
quantity: json['quantity'] as String,
pos: json['pos'] as String,
);
Map<String, dynamic> _$ComponentDTOToJson(ComponentDTO instance) =>
<String, dynamic>{
'article_nr': instance.articleNr,
'name': instance.name,
'quantity': instance.quantity,
'pos': instance.pos,
};

View File

@ -1,20 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'contact_person.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class ContactPersonDTO {
ContactPersonDTO(
{required this.name,
required this.salutation,
required this.phoneNo,
required this.mobileNo});
String name;
String salutation;
String phoneNo;
String mobileNo;
factory ContactPersonDTO.fromJson(Map<String, dynamic> json) => _$ContactPersonDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$ContactPersonDTOToJson(this);
}

View File

@ -1,23 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'contact_person.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ContactPersonDTO _$ContactPersonDTOFromJson(Map<String, dynamic> json) =>
ContactPersonDTO(
name: json['name'] as String,
salutation: json['salutation'] as String,
phoneNo: json['phone_no'] as String,
mobileNo: json['mobile_no'] as String,
);
Map<String, dynamic> _$ContactPersonDTOToJson(ContactPersonDTO instance) =>
<String, dynamic>{
'name': instance.name,
'salutation': instance.salutation,
'phone_no': instance.phoneNo,
'mobile_no': instance.mobileNo,
};

View File

@ -1,16 +0,0 @@
import 'address.dart';
import 'package:json_annotation/json_annotation.dart';
part 'customer.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class CustomerDTO {
CustomerDTO({required this.name, required this.address, this.eMail});
String name;
AddressDTO address;
String? eMail;
factory CustomerDTO.fromJson(Map<String, dynamic> json) => _$CustomerDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$CustomerDTOToJson(this);
}

View File

@ -1,20 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'customer.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CustomerDTO _$CustomerDTOFromJson(Map<String, dynamic> json) => CustomerDTO(
name: json['name'] as String,
address: AddressDTO.fromJson(json['address'] as Map<String, dynamic>),
eMail: json['e_mail'] as String?,
);
Map<String, dynamic> _$CustomerDTOToJson(CustomerDTO instance) =>
<String, dynamic>{
'name': instance.name,
'address': instance.address,
'e_mail': instance.eMail,
};

View File

@ -1,84 +0,0 @@
import 'package:hl_lieferservice/dto/image_note_response.dart';
import 'article.dart';
import 'contact_person.dart';
import 'customer.dart';
import 'discount.dart';
import 'note.dart';
import 'payment.dart';
import 'package:json_annotation/json_annotation.dart';
part 'delivery.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class DeliveryOptionDTO {
DeliveryOptionDTO({
required this.numerical,
required this.value,
required this.display,
required this.key,
});
bool numerical;
String value;
String display;
String key;
factory DeliveryOptionDTO.fromJson(Map<String, dynamic> json) =>
_$DeliveryOptionDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$DeliveryOptionDTOToJson(this);
}
@JsonSerializable(fieldRename: FieldRename.snake)
class DeliveryDTO {
DeliveryDTO({
required this.internalReceiptNo,
required this.specialAggreements,
required this.currency,
required this.notes,
required this.totalPrice,
required this.prepayment,
required this.paymentAtDelivery,
required this.desiredTime,
required this.contactPerson,
required this.articles,
required this.totalNetValue,
required this.totalGrossValue,
required this.images,
required this.customer,
required this.finishedTime,
required this.note,
required this.state,
required this.payment,
required this.carId,
required this.options,
});
String internalReceiptNo;
String? specialAggreements;
CustomerDTO customer;
String totalPrice;
String desiredTime;
String totalGrossValue;
String totalNetValue;
ContactPersonDTO contactPerson;
String? currency;
List<ArticleDTO> articles;
String note;
String finishedTime;
String carId;
String state;
String prepayment;
String paymentAtDelivery;
DiscountDTO? discount;
PaymentMethodDTO payment;
List<NoteDTO> notes;
List<ImageNoteDTO> images;
List<DeliveryOptionDTO> options;
factory DeliveryDTO.fromJson(Map<String, dynamic> json) =>
_$DeliveryDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$DeliveryDTOToJson(this);
}

View File

@ -1,89 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'delivery.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DeliveryOptionDTO _$DeliveryOptionDTOFromJson(Map<String, dynamic> json) =>
DeliveryOptionDTO(
numerical: json['numerical'] as bool,
value: json['value'] as String,
display: json['display'] as String,
key: json['key'] as String,
);
Map<String, dynamic> _$DeliveryOptionDTOToJson(DeliveryOptionDTO instance) =>
<String, dynamic>{
'numerical': instance.numerical,
'value': instance.value,
'display': instance.display,
'key': instance.key,
};
DeliveryDTO _$DeliveryDTOFromJson(Map<String, dynamic> json) => DeliveryDTO(
internalReceiptNo: json['internal_receipt_no'] as String,
specialAggreements: json['special_aggreements'] as String?,
currency: json['currency'] as String?,
notes:
(json['notes'] as List<dynamic>)
.map((e) => NoteDTO.fromJson(e as Map<String, dynamic>))
.toList(),
totalPrice: json['total_price'] as String,
prepayment: json['prepayment'] as String,
paymentAtDelivery: json['payment_at_delivery'] as String,
desiredTime: json['desired_time'] as String,
contactPerson: ContactPersonDTO.fromJson(
json['contact_person'] as Map<String, dynamic>,
),
articles:
(json['articles'] as List<dynamic>)
.map((e) => ArticleDTO.fromJson(e as Map<String, dynamic>))
.toList(),
totalNetValue: json['total_net_value'] as String,
totalGrossValue: json['total_gross_value'] as String,
images:
(json['images'] as List<dynamic>)
.map((e) => ImageNoteDTO.fromJson(e as Map<String, dynamic>))
.toList(),
customer: CustomerDTO.fromJson(json['customer'] as Map<String, dynamic>),
finishedTime: json['finished_time'] as String,
note: json['note'] as String,
state: json['state'] as String,
payment: PaymentMethodDTO.fromJson(json['payment'] as Map<String, dynamic>),
carId: json['car_id'] as String,
options:
(json['options'] as List<dynamic>)
.map((e) => DeliveryOptionDTO.fromJson(e as Map<String, dynamic>))
.toList(),
)
..discount =
json['discount'] == null
? null
: DiscountDTO.fromJson(json['discount'] as Map<String, dynamic>);
Map<String, dynamic> _$DeliveryDTOToJson(DeliveryDTO instance) =>
<String, dynamic>{
'internal_receipt_no': instance.internalReceiptNo,
'special_aggreements': instance.specialAggreements,
'customer': instance.customer,
'total_price': instance.totalPrice,
'desired_time': instance.desiredTime,
'total_gross_value': instance.totalGrossValue,
'total_net_value': instance.totalNetValue,
'contact_person': instance.contactPerson,
'currency': instance.currency,
'articles': instance.articles,
'note': instance.note,
'finished_time': instance.finishedTime,
'car_id': instance.carId,
'state': instance.state,
'prepayment': instance.prepayment,
'payment_at_delivery': instance.paymentAtDelivery,
'discount': instance.discount,
'payment': instance.payment,
'notes': instance.notes,
'images': instance.images,
'options': instance.options,
};

View File

@ -1,22 +0,0 @@
import 'delivery.dart';
import 'driver.dart';
import 'package:json_annotation/json_annotation.dart';
part 'delivery_response.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class DeliveryResponseDTO {
DeliveryResponseDTO(
{required this.deliveries,
required this.driver,
required this.discountArticleNumber});
List<DeliveryDTO> deliveries;
DriverDTO driver;
String discountArticleNumber;
factory DeliveryResponseDTO.fromJson(Map<String, dynamic> json) =>
_$DeliveryResponseDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$DeliveryResponseDTOToJson(this);
}

View File

@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'delivery_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DeliveryResponseDTO _$DeliveryResponseDTOFromJson(Map<String, dynamic> json) =>
DeliveryResponseDTO(
deliveries:
(json['deliveries'] as List<dynamic>)
.map((e) => DeliveryDTO.fromJson(e as Map<String, dynamic>))
.toList(),
driver: DriverDTO.fromJson(json['driver'] as Map<String, dynamic>),
discountArticleNumber: json['discount_article_number'] as String,
);
Map<String, dynamic> _$DeliveryResponseDTOToJson(
DeliveryResponseDTO instance,
) => <String, dynamic>{
'deliveries': instance.deliveries,
'driver': instance.driver,
'discount_article_number': instance.discountArticleNumber,
};

View File

@ -1,85 +0,0 @@
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:intl/intl.dart';
import 'package:json_annotation/json_annotation.dart';
part 'delivery_update.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class DeliveryOptionUpdateDTO {
DeliveryOptionUpdateDTO({
required this.numerical,
required this.value,
required this.key,
});
bool numerical;
String value;
String key;
factory DeliveryOptionUpdateDTO.fromJson(Map<String, dynamic> json) =>
_$DeliveryOptionUpdateDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$DeliveryOptionUpdateDTOToJson(this);
factory DeliveryOptionUpdateDTO.fromEntity(DeliveryOption option) {
return DeliveryOptionUpdateDTO(
numerical: option.numerical,
value: option.value,
key: option.key,
);
}
}
@JsonSerializable(fieldRename: FieldRename.snake)
class DeliveryUpdateDTO {
DeliveryUpdateDTO({
required this.deliveryId,
this.finishedDate,
this.selectedPaymentMethodId,
this.options,
this.state,
this.carId,
});
String deliveryId;
String? finishedDate;
String? state;
String? carId;
String? selectedPaymentMethodId;
List<DeliveryOptionUpdateDTO>? options;
factory DeliveryUpdateDTO.fromJson(Map<String, dynamic> json) =>
_$DeliveryUpdateDTOFromJson(json);
factory DeliveryUpdateDTO.fromEntity(Delivery delivery) {
String state = "";
switch (delivery.state) {
case DeliveryState.finished:
state = "geliefert";
break;
case DeliveryState.ongoing:
state = "laufend";
break;
case DeliveryState.onhold:
state = "vertagt";
break;
case DeliveryState.canceled:
state = "abgebrochen";
break;
}
return DeliveryUpdateDTO(
deliveryId: delivery.id,
state: state,
carId: delivery.carId?.toString() ,
selectedPaymentMethodId: delivery.payment.id,
options: delivery.options.map(DeliveryOptionUpdateDTO.fromEntity).toList(),
finishedDate: delivery.state == DeliveryState.finished
? DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now())
: null,
);
}
Map<dynamic, dynamic> toJson() => _$DeliveryUpdateDTOToJson(this);
}

View File

@ -1,49 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'delivery_update.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DeliveryOptionUpdateDTO _$DeliveryOptionUpdateDTOFromJson(
Map<String, dynamic> json,
) => DeliveryOptionUpdateDTO(
numerical: json['numerical'] as bool,
value: json['value'] as String,
key: json['key'] as String,
);
Map<String, dynamic> _$DeliveryOptionUpdateDTOToJson(
DeliveryOptionUpdateDTO instance,
) => <String, dynamic>{
'numerical': instance.numerical,
'value': instance.value,
'key': instance.key,
};
DeliveryUpdateDTO _$DeliveryUpdateDTOFromJson(Map<String, dynamic> json) =>
DeliveryUpdateDTO(
deliveryId: json['delivery_id'] as String,
finishedDate: json['finished_date'] as String?,
selectedPaymentMethodId: json['selected_payment_method_id'] as String?,
options:
(json['options'] as List<dynamic>?)
?.map(
(e) =>
DeliveryOptionUpdateDTO.fromJson(e as Map<String, dynamic>),
)
.toList(),
state: json['state'] as String?,
carId: json['car_id'] as String?,
);
Map<String, dynamic> _$DeliveryUpdateDTOToJson(DeliveryUpdateDTO instance) =>
<String, dynamic>{
'delivery_id': instance.deliveryId,
'finished_date': instance.finishedDate,
'state': instance.state,
'car_id': instance.carId,
'selected_payment_method_id': instance.selectedPaymentMethodId,
'options': instance.options,
};

View File

@ -1,17 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'delivery_update_response.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class DeliveryUpdateResponseDTO {
DeliveryUpdateResponseDTO(
{required this.message, required this.code});
final String code;
final String message;
factory DeliveryUpdateResponseDTO.fromJson(Map<String, dynamic> json) =>
_$DeliveryUpdateResponseDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$DeliveryUpdateResponseDTOToJson(this);
}

View File

@ -1,18 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'delivery_update_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DeliveryUpdateResponseDTO _$DeliveryUpdateResponseDTOFromJson(
Map<String, dynamic> json,
) => DeliveryUpdateResponseDTO(
message: json['message'] as String,
code: json['code'] as String,
);
Map<String, dynamic> _$DeliveryUpdateResponseDTOToJson(
DeliveryUpdateResponseDTO instance,
) => <String, dynamic>{'code': instance.code, 'message': instance.message};

View File

@ -1,15 +0,0 @@
import 'article.dart';
import 'package:json_annotation/json_annotation.dart';
part 'discount.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class DiscountDTO {
DiscountDTO({required this.note, required this.noteId, required this.article});
String? note;
String? noteId;
ArticleDTO article;
factory DiscountDTO.fromJson(Map<String, dynamic> json) => _$DiscountDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$DiscountDTOToJson(this);
}

View File

@ -1,20 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'discount.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DiscountDTO _$DiscountDTOFromJson(Map<String, dynamic> json) => DiscountDTO(
note: json['note'] as String?,
noteId: json['note_id'] as String?,
article: ArticleDTO.fromJson(json['article'] as Map<String, dynamic>),
);
Map<String, dynamic> _$DiscountDTOToJson(DiscountDTO instance) =>
<String, dynamic>{
'note': instance.note,
'note_id': instance.noteId,
'article': instance.article,
};

View File

@ -1,18 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'discount_add.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class DiscountAddDTO {
DiscountAddDTO(
{required this.note, required this.deliveryId, required this.discount});
String note;
String deliveryId;
int discount;
factory DiscountAddDTO.fromJson(Map<String, dynamic> json) =>
_$DiscountAddDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$DiscountAddDTOToJson(this);
}

View File

@ -1,21 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'discount_add.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DiscountAddDTO _$DiscountAddDTOFromJson(Map<String, dynamic> json) =>
DiscountAddDTO(
note: json['note'] as String,
deliveryId: json['delivery_id'] as String,
discount: (json['discount'] as num).toInt(),
);
Map<String, dynamic> _$DiscountAddDTOToJson(DiscountAddDTO instance) =>
<String, dynamic>{
'note': instance.note,
'delivery_id': instance.deliveryId,
'discount': instance.discount,
};

View File

@ -1,64 +0,0 @@
import 'article.dart';
import 'basic_response.dart';
import 'package:json_annotation/json_annotation.dart';
part 'discount_add_response.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class PriceInformation {
PriceInformation({required this.net, required this.gross});
double net;
double gross;
factory PriceInformation.fromJson(Map<String, dynamic> json) =>
_$PriceInformationFromJson(json);
Map<dynamic, dynamic> toJson() => _$PriceInformationToJson(this);
}
@JsonSerializable(fieldRename: FieldRename.snake)
class NoteInformation {
NoteInformation({required this.rowId, required this.noteDescription});
String rowId;
String noteDescription;
factory NoteInformation.fromJson(Map<String, dynamic> json) =>
_$NoteInformationFromJson(json);
Map<dynamic, dynamic> toJson() => _$NoteInformationToJson(this);
}
@JsonSerializable(fieldRename: FieldRename.snake)
class UpdatedValues {
UpdatedValues(
{required this.discount,
required this.receipt,
required this.article,
required this.note});
PriceInformation discount;
PriceInformation receipt;
NoteInformation note;
ArticleDTO article;
factory UpdatedValues.fromJson(Map<String, dynamic> json) =>
_$UpdatedValuesFromJson(json);
Map<dynamic, dynamic> toJson() => _$UpdatedValuesToJson(this);
}
@JsonSerializable(fieldRename: FieldRename.snake)
class DiscountAddResponseDTO extends BasicResponseDTO {
DiscountAddResponseDTO(
{required this.values, required super.succeeded, required super.message});
UpdatedValues values;
factory DiscountAddResponseDTO.fromJson(Map<String, dynamic> json) =>
_$DiscountAddResponseDTOFromJson(json);
@override
Map<dynamic, dynamic> toJson() => _$DiscountAddResponseDTOToJson(this);
}

View File

@ -1,61 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'discount_add_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PriceInformation _$PriceInformationFromJson(Map<String, dynamic> json) =>
PriceInformation(
net: (json['net'] as num).toDouble(),
gross: (json['gross'] as num).toDouble(),
);
Map<String, dynamic> _$PriceInformationToJson(PriceInformation instance) =>
<String, dynamic>{'net': instance.net, 'gross': instance.gross};
NoteInformation _$NoteInformationFromJson(Map<String, dynamic> json) =>
NoteInformation(
rowId: json['row_id'] as String,
noteDescription: json['note_description'] as String,
);
Map<String, dynamic> _$NoteInformationToJson(NoteInformation instance) =>
<String, dynamic>{
'row_id': instance.rowId,
'note_description': instance.noteDescription,
};
UpdatedValues _$UpdatedValuesFromJson(
Map<String, dynamic> json,
) => UpdatedValues(
discount: PriceInformation.fromJson(json['discount'] as Map<String, dynamic>),
receipt: PriceInformation.fromJson(json['receipt'] as Map<String, dynamic>),
article: ArticleDTO.fromJson(json['article'] as Map<String, dynamic>),
note: NoteInformation.fromJson(json['note'] as Map<String, dynamic>),
);
Map<String, dynamic> _$UpdatedValuesToJson(UpdatedValues instance) =>
<String, dynamic>{
'discount': instance.discount,
'receipt': instance.receipt,
'note': instance.note,
'article': instance.article,
};
DiscountAddResponseDTO _$DiscountAddResponseDTOFromJson(
Map<String, dynamic> json,
) => DiscountAddResponseDTO(
values: UpdatedValues.fromJson(json['values'] as Map<String, dynamic>),
succeeded: json['succeeded'] as bool,
message: json['message'] as String,
);
Map<String, dynamic> _$DiscountAddResponseDTOToJson(
DiscountAddResponseDTO instance,
) => <String, dynamic>{
'succeeded': instance.succeeded,
'message': instance.message,
'values': instance.values,
};

View File

@ -1,15 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'discount_remove.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class DiscountRemoveDTO {
DiscountRemoveDTO(
{required this.deliveryId});
String deliveryId;
factory DiscountRemoveDTO.fromJson(Map<String, dynamic> json) =>
_$DiscountRemoveDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$DiscountRemoveDTOToJson(this);
}

View File

@ -1,13 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'discount_remove.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DiscountRemoveDTO _$DiscountRemoveDTOFromJson(Map<String, dynamic> json) =>
DiscountRemoveDTO(deliveryId: json['delivery_id'] as String);
Map<String, dynamic> _$DiscountRemoveDTOToJson(DiscountRemoveDTO instance) =>
<String, dynamic>{'delivery_id': instance.deliveryId};

View File

@ -1,23 +0,0 @@
import 'basic_response.dart';
import 'package:json_annotation/json_annotation.dart';
import 'discount_add_response.dart';
part 'discount_remove_response.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class DiscountRemoveResponseDTO extends BasicResponseDTO {
DiscountRemoveResponseDTO(
{
required this.receipt,
required super.succeeded,
required super.message});
PriceInformation receipt;
factory DiscountRemoveResponseDTO.fromJson(Map<String, dynamic> json) =>
_$DiscountRemoveResponseDTOFromJson(json);
@override
Map<dynamic, dynamic> toJson() => _$DiscountRemoveResponseDTOToJson(this);
}

View File

@ -1,23 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'discount_remove_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DiscountRemoveResponseDTO _$DiscountRemoveResponseDTOFromJson(
Map<String, dynamic> json,
) => DiscountRemoveResponseDTO(
receipt: PriceInformation.fromJson(json['receipt'] as Map<String, dynamic>),
succeeded: json['succeeded'] as bool,
message: json['message'] as String,
);
Map<String, dynamic> _$DiscountRemoveResponseDTOToJson(
DiscountRemoveResponseDTO instance,
) => <String, dynamic>{
'succeeded': instance.succeeded,
'message': instance.message,
'receipt': instance.receipt,
};

View File

@ -1,18 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'discount_update.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class DiscountUpdateDTO {
DiscountUpdateDTO(
{required this.note, required this.deliveryId, required this.discount});
String? note;
String deliveryId;
int? discount;
factory DiscountUpdateDTO.fromJson(Map<String, dynamic> json) =>
_$DiscountUpdateDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$DiscountUpdateDTOToJson(this);
}

View File

@ -1,21 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'discount_update.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DiscountUpdateDTO _$DiscountUpdateDTOFromJson(Map<String, dynamic> json) =>
DiscountUpdateDTO(
note: json['note'] as String?,
deliveryId: json['delivery_id'] as String,
discount: (json['discount'] as num?)?.toInt(),
);
Map<String, dynamic> _$DiscountUpdateDTOToJson(DiscountUpdateDTO instance) =>
<String, dynamic>{
'note': instance.note,
'delivery_id': instance.deliveryId,
'discount': instance.discount,
};

View File

@ -1,22 +0,0 @@
import 'basic_response.dart';
import 'discount_add_response.dart';
import 'package:json_annotation/json_annotation.dart';
part 'discount_update_response.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class DiscountUpdateResponseDTO extends BasicResponseDTO {
DiscountUpdateResponseDTO(
{required this.values,
required super.succeeded,
required super.message});
UpdatedValues? values;
factory DiscountUpdateResponseDTO.fromJson(Map<String, dynamic> json) =>
_$DiscountUpdateResponseDTOFromJson(json);
@override
Map<dynamic, dynamic> toJson() => _$DiscountUpdateResponseDTOToJson(this);
}

View File

@ -1,26 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'discount_update_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DiscountUpdateResponseDTO _$DiscountUpdateResponseDTOFromJson(
Map<String, dynamic> json,
) => DiscountUpdateResponseDTO(
values:
json['values'] == null
? null
: UpdatedValues.fromJson(json['values'] as Map<String, dynamic>),
succeeded: json['succeeded'] as bool,
message: json['message'] as String,
);
Map<String, dynamic> _$DiscountUpdateResponseDTOToJson(
DiscountUpdateResponseDTO instance,
) => <String, dynamic>{
'succeeded': instance.succeeded,
'message': instance.message,
'values': instance.values,
};

View File

@ -1,16 +0,0 @@
import 'car.dart';
import 'package:json_annotation/json_annotation.dart';
part 'driver.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class DriverDTO {
DriverDTO({required this.id, required this.name, required this.salutation, required this.cars});
String id;
String name;
String salutation;
List<CarDTO> cars;
factory DriverDTO.fromJson(Map<String, dynamic> json) => _$DriverDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$DriverDTOToJson(this);
}

View File

@ -1,24 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'driver.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DriverDTO _$DriverDTOFromJson(Map<String, dynamic> json) => DriverDTO(
id: json['id'] as String,
name: json['name'] as String,
salutation: json['salutation'] as String,
cars:
(json['cars'] as List<dynamic>)
.map((e) => CarDTO.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$DriverDTOToJson(DriverDTO instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'salutation': instance.salutation,
'cars': instance.cars,
};

View File

@ -1,18 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'image.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class ImageDTO {
ImageDTO(
{required this.url, required this.name, required this.oid});
String url;
String name;
String oid;
factory ImageDTO.fromJson(Map<String, dynamic> json) =>
_$ImageDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$ImageDTOToJson(this);
}

View File

@ -1,19 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'image.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ImageDTO _$ImageDTOFromJson(Map<String, dynamic> json) => ImageDTO(
url: json['url'] as String,
name: json['name'] as String,
oid: json['oid'] as String,
);
Map<String, dynamic> _$ImageDTOToJson(ImageDTO instance) => <String, dynamic>{
'url': instance.url,
'name': instance.name,
'oid': instance.oid,
};

View File

@ -1,17 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'image_note_response.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class ImageNoteDTO {
final String url;
final String oid;
final String name;
ImageNoteDTO({required this.url, required this.oid, required this.name});
factory ImageNoteDTO.fromJson(Map<String, dynamic> json) =>
_$ImageNoteDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$ImageNoteDTOToJson(this);
}

View File

@ -1,20 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'image_note_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ImageNoteDTO _$ImageNoteDTOFromJson(Map<String, dynamic> json) => ImageNoteDTO(
url: json['url'] as String,
oid: json['oid'] as String,
name: json['name'] as String,
);
Map<String, dynamic> _$ImageNoteDTOToJson(ImageNoteDTO instance) =>
<String, dynamic>{
'url': instance.url,
'oid': instance.oid,
'name': instance.name,
};

View File

@ -1,16 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'note.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class NoteDTO {
NoteDTO(
{required this.id,
required this.note});
final String id;
final String note;
factory NoteDTO.fromJson(Map<String, dynamic> json) => _$NoteDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$NoteDTOToJson(this);
}

View File

@ -1,15 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'note.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
NoteDTO _$NoteDTOFromJson(Map<String, dynamic> json) =>
NoteDTO(id: json['id'] as String, note: json['note'] as String);
Map<String, dynamic> _$NoteDTOToJson(NoteDTO instance) => <String, dynamic>{
'id': instance.id,
'note': instance.note,
};

View File

@ -1,17 +0,0 @@
import 'package:hl_lieferservice/dto/basic_response.dart';
import 'package:hl_lieferservice/dto/note.dart';
import 'package:json_annotation/json_annotation.dart';
part 'note_add_response.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class NoteAddResponseDTO extends BasicResponseDTO {
NoteAddResponseDTO(
{required this.note, required super.succeeded, required super.message});
final NoteDTO? note;
factory NoteAddResponseDTO.fromJson(Map<String, dynamic> json) => _$NoteAddResponseDTOFromJson(json);
@override
Map<dynamic, dynamic> toJson() => _$NoteAddResponseDTOToJson(this);
}

View File

@ -1,24 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'note_add_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
NoteAddResponseDTO _$NoteAddResponseDTOFromJson(Map<String, dynamic> json) =>
NoteAddResponseDTO(
note:
json['note'] == null
? null
: NoteDTO.fromJson(json['note'] as Map<String, dynamic>),
succeeded: json['succeeded'] as bool,
message: json['message'] as String,
);
Map<String, dynamic> _$NoteAddResponseDTOToJson(NoteAddResponseDTO instance) =>
<String, dynamic>{
'succeeded': instance.succeeded,
'message': instance.message,
'note': instance.note,
};

View File

@ -1,22 +0,0 @@
import 'package:hl_lieferservice/dto/image_note_response.dart';
import 'note.dart';
import 'package:json_annotation/json_annotation.dart';
part 'note_get_response.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class NoteGetResponseDTO {
NoteGetResponseDTO(
{required this.notes, required this.succeeded, required this.message, required this.images});
final List<NoteDTO> notes;
final List<ImageNoteDTO> images;
final bool succeeded;
final String message;
factory NoteGetResponseDTO.fromJson(Map<String, dynamic> json) =>
_$NoteGetResponseDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$NoteGetResponseDTOToJson(this);
}

View File

@ -1,29 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'note_get_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
NoteGetResponseDTO _$NoteGetResponseDTOFromJson(Map<String, dynamic> json) =>
NoteGetResponseDTO(
notes:
(json['notes'] as List<dynamic>)
.map((e) => NoteDTO.fromJson(e as Map<String, dynamic>))
.toList(),
succeeded: json['succeeded'] as bool,
message: json['message'] as String,
images:
(json['images'] as List<dynamic>)
.map((e) => ImageNoteDTO.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$NoteGetResponseDTOToJson(NoteGetResponseDTO instance) =>
<String, dynamic>{
'notes': instance.notes,
'images': instance.images,
'succeeded': instance.succeeded,
'message': instance.message,
};

View File

@ -1,18 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'note_template.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class NoteTemplateDTO {
NoteTemplateDTO(
{required this.language,
required this.title,
required this.note});
final String note;
final String language;
final String title;
factory NoteTemplateDTO.fromJson(Map<String, dynamic> json) => _$NoteTemplateDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$NoteTemplateDTOToJson(this);
}

View File

@ -1,21 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'note_template.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
NoteTemplateDTO _$NoteTemplateDTOFromJson(Map<String, dynamic> json) =>
NoteTemplateDTO(
language: json['language'] as String,
title: json['title'] as String,
note: json['note'] as String,
);
Map<String, dynamic> _$NoteTemplateDTOToJson(NoteTemplateDTO instance) =>
<String, dynamic>{
'note': instance.note,
'language': instance.language,
'title': instance.title,
};

View File

@ -1,19 +0,0 @@
import 'note_template.dart';
import 'package:json_annotation/json_annotation.dart';
part 'note_template_response.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class NoteTemplateResponseDTO {
NoteTemplateResponseDTO(
{required this.notes, required this.succeeded, required this.message});
final List<NoteTemplateDTO> notes;
final bool succeeded;
final String message;
factory NoteTemplateResponseDTO.fromJson(Map<String, dynamic> json) =>
_$NoteTemplateResponseDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$NoteTemplateResponseDTOToJson(this);
}

View File

@ -1,26 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'note_template_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
NoteTemplateResponseDTO _$NoteTemplateResponseDTOFromJson(
Map<String, dynamic> json,
) => NoteTemplateResponseDTO(
notes:
(json['notes'] as List<dynamic>)
.map((e) => NoteTemplateDTO.fromJson(e as Map<String, dynamic>))
.toList(),
succeeded: json['succeeded'] as bool,
message: json['message'] as String,
);
Map<String, dynamic> _$NoteTemplateResponseDTOToJson(
NoteTemplateResponseDTO instance,
) => <String, dynamic>{
'notes': instance.notes,
'succeeded': instance.succeeded,
'message': instance.message,
};

View File

@ -1,17 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'payment.g.dart';
@JsonSerializable()
class PaymentMethodDTO {
PaymentMethodDTO(this.id,
{required this.description,
required this.shortCode});
final String id;
final String description;
final String shortCode;
factory PaymentMethodDTO.fromJson(Map<String, dynamic> json) => _$PaymentMethodDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$PaymentMethodDTOToJson(this);
}

View File

@ -1,21 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'payment.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PaymentMethodDTO _$PaymentMethodDTOFromJson(Map<String, dynamic> json) =>
PaymentMethodDTO(
json['id'] as String,
description: json['description'] as String,
shortCode: json['shortCode'] as String,
);
Map<String, dynamic> _$PaymentMethodDTOToJson(PaymentMethodDTO instance) =>
<String, dynamic>{
'id': instance.id,
'description': instance.description,
'shortCode': instance.shortCode,
};

View File

@ -1,14 +0,0 @@
import 'payment.dart';
import 'package:json_annotation/json_annotation.dart';
part 'payments.g.dart';
@JsonSerializable()
class PaymentMethodListDTO {
PaymentMethodListDTO({required this.paymentMethods});
final List<PaymentMethodDTO> paymentMethods;
factory PaymentMethodListDTO.fromJson(Map<String, dynamic> json) => _$PaymentMethodListDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$PaymentMethodListDTOToJson(this);
}

View File

@ -1,20 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'payments.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PaymentMethodListDTO _$PaymentMethodListDTOFromJson(
Map<String, dynamic> json,
) => PaymentMethodListDTO(
paymentMethods:
(json['paymentMethods'] as List<dynamic>)
.map((e) => PaymentMethodDTO.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$PaymentMethodListDTOToJson(
PaymentMethodListDTO instance,
) => <String, dynamic>{'paymentMethods': instance.paymentMethods};

View File

@ -1,15 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'scan.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class ScanDTO {
ScanDTO({required this.internalId});
String internalId;
factory ScanDTO.fromJson(Map<String, dynamic> json) =>
_$ScanDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$ScanDTOToJson(this);
}

View File

@ -1,14 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'scan.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ScanDTO _$ScanDTOFromJson(Map<String, dynamic> json) =>
ScanDTO(internalId: json['internal_id'] as String);
Map<String, dynamic> _$ScanDTOToJson(ScanDTO instance) => <String, dynamic>{
'internal_id': instance.internalId,
};

View File

@ -1,18 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'scan_response.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class ScanResponseDTO {
ScanResponseDTO(
{required this.message, required this.succeeded, required this.noteId});
final bool succeeded;
final String message;
final String? noteId;
factory ScanResponseDTO.fromJson(Map<String, dynamic> json) =>
_$ScanResponseDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$ScanResponseDTOToJson(this);
}

View File

@ -1,21 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'scan_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ScanResponseDTO _$ScanResponseDTOFromJson(Map<String, dynamic> json) =>
ScanResponseDTO(
message: json['message'] as String,
succeeded: json['succeeded'] as bool,
noteId: json['note_id'] as String?,
);
Map<String, dynamic> _$ScanResponseDTOToJson(ScanResponseDTO instance) =>
<String, dynamic>{
'succeeded': instance.succeeded,
'message': instance.message,
'note_id': instance.noteId,
};

View File

@ -1,23 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'set_article_amount_request.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class SetArticleAmountRequestDTO {
SetArticleAmountRequestDTO({
required this.articleId,
required this.deliveryId,
required this.amount,
this.reason
});
String deliveryId;
int amount;
String articleId;
String? reason;
factory SetArticleAmountRequestDTO.fromJson(Map<String, dynamic> json) =>
_$SetArticleAmountRequestDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$SetArticleAmountRequestDTOToJson(this);
}

View File

@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'set_article_amount_request.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SetArticleAmountRequestDTO _$SetArticleAmountRequestDTOFromJson(
Map<String, dynamic> json,
) => SetArticleAmountRequestDTO(
articleId: json['article_id'] as String,
deliveryId: json['delivery_id'] as String,
amount: (json['amount'] as num).toInt(),
reason: json['reason'] as String?,
);
Map<String, dynamic> _$SetArticleAmountRequestDTOToJson(
SetArticleAmountRequestDTO instance,
) => <String, dynamic>{
'delivery_id': instance.deliveryId,
'amount': instance.amount,
'article_id': instance.articleId,
'reason': instance.reason,
};

View File

@ -1,21 +0,0 @@
import 'package:hl_lieferservice/dto/basic_response.dart';
import 'package:json_annotation/json_annotation.dart';
part 'set_article_amount_response.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class SetArticleAmountResponseDTO extends BasicResponseDTO {
SetArticleAmountResponseDTO({
required super.succeeded,
required super.message,
this.noteId
});
String? noteId;
factory SetArticleAmountResponseDTO.fromJson(Map<String, dynamic> json) =>
_$SetArticleAmountResponseDTOFromJson(json);
@override
Map<dynamic, dynamic> toJson() => _$SetArticleAmountResponseDTOToJson(this);
}

View File

@ -1,23 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'set_article_amount_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SetArticleAmountResponseDTO _$SetArticleAmountResponseDTOFromJson(
Map<String, dynamic> json,
) => SetArticleAmountResponseDTO(
succeeded: json['succeeded'] as bool,
message: json['message'] as String,
noteId: json['note_id'] as String?,
);
Map<String, dynamic> _$SetArticleAmountResponseDTOToJson(
SetArticleAmountResponseDTO instance,
) => <String, dynamic>{
'succeeded': instance.succeeded,
'message': instance.message,
'note_id': instance.noteId,
};

View File

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

View File

@ -81,7 +81,16 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
Emitter<AuthState> emit, Emitter<AuthState> emit,
) async { ) async {
try { try {
final restored = await tokenProvider.restoreSession(); // Timeout-Schutz: hängt der Restore (z. B. nativer flutter_appauth-
// Token-Call nach Hot-Restart, nicht erreichbarer Issuer), darf der
// Bootstrap NICHT ewig im Splash bleiben. Nach dem Timeout fallen wir
// sauber auf die LoginPage zurück. Läuft der Restore später doch noch
// erfolgreich durch, kommt der Login via Stream-Event (AuthLoggedIn)
// nachträglich an und der State wird zu Authenticated.
final restored = await tokenProvider.restoreSession().timeout(
const Duration(seconds: 15),
onTimeout: () => false,
);
if (!restored) { if (!restored) {
// Kein gespeicherter Refresh-Token oder Refresh fehlgeschlagen: // Kein gespeicherter Refresh-Token oder Refresh fehlgeschlagen:
// Vom Splash zur LoginPage übergehen. Kein Snackbar — das ist // Vom Splash zur LoginPage übergehen. Kein Snackbar — das ist

Some files were not shown because too many files have changed in this diff Show More