Compare commits

...

9 Commits

Author SHA1 Message Date
a9bf8ecdd1 Final commit. 2026-06-01 17:12:28 +02:00
3ecbc82885 Phase C+D-1: Cars-Domain auf Rust-Backend umgestellt
Clean-Arch-Schichten für Cars:
- lib/domain/entity/car.dart: UUID-id, accountId (Personalnummer),
  plate, active. Pendant zum Backend-Schema.
- lib/domain/repository/cars_repository.dart: Port — listMine,
  create, update. Keine teamId/personalnummer-Parameter, der
  Account fließt serverseitig aus dem JWT.
- lib/data/mapper/car_mapper.dart: API-DTO (built_value) → Domain.
- lib/data/repository/cars_repository_impl.dart: konkrete Impl via
  generierter CarsApi (dio), mit DioException → CarsRepositoryException-
  Übersetzung.

Feature-Cars-Refactoring:
- CarsBloc nimmt jetzt die Domain-Repository-Schnittstelle. Events:
  CarLoad/CarAdd/CarEdit/CarDeactivate (statt CarDelete). Keine
  teamId-Parameter mehr. Kein authBloc-Bezug, Session-Expiry läuft
  über den globalen Provider-Stream.
- CarsState sealed mit CarsInitial/Loading/LoadingFailed/Loaded.
- Pages: car_management_page, car_management, car_card, car_fail_page,
  car_selection_page komplett auf die neue Entity und Event-Signaturen.
- Alte lib/feature/cars/service/cars_service.dart und
  lib/feature/cars/repository/cars_repository.dart gelöscht.

CarSelectBloc + Storage:
- CarSelection.selectedCarId von int? auf String? umgestellt.
- CarSelectionRepository persistiert die UUID jetzt als String;
  defensive Migration für noch vorhandene int-Werte (alte
  Pre-Migration-Installations) verwirft den Wert leise und
  erzwingt Neuauswahl.

Konsequenz-Cleanup im Tour-Code (Phase-D-Vorbereitung):
- Delivery.carId String? statt int?.
- Tour.hasUndeliveredLoadedArticles / getFinishedDeliveries auf
  String carId.
- _selectedCarId / int? carId / int selectedCarId in DeliveryOverview,
  LoadingCustomerPage/OverviewPage, Home, DeliverySelection/SortPage,
  DeliveryInfo/List, CustomSortDialog, SortableDeliveryList auf
  String umgestellt.
- TourRepository ersetzt int.parse(carId)/int.tryParse-Zuweisungen
  direkt durch String.
- lib/model/car.dart wird zum Re-Export der neuen Domain-Entity,
  damit Legacy-Imports während Phase-D-Übergang weiter compilieren.

DI:
- app.dart: CarsBloc bekommt CarsRepositoryImpl(locator<HolzleitnerApi>())
  statt der alten CarsRepository(service: CarService()).

Build (flutter build apk --debug) durch, flutter analyze ohne
errors.
2026-05-15 11:55:24 +02:00
e369d1ceb2 Phase B+1 Nachzügler: LAN-IP-Config, SessionExpired-Legacy, Logout in CarSelection
- BackendConfig.localDev nutzt jetzt die LAN-IP des Dev-Macs
  (192.168.0.138) statt localhost. Notwendig zum Testen auf einem
  realen Android-Gerät über WLAN. Auf dem iOS-Simulator
  zurückwechseln oder per Build-Flag injizieren.
- AuthBloc.on<SessionExpiredEvent> wird zum No-Op (mit Log).
  Begründung: die alten ERPframe-Repos rufen das nach jedem 401 auf,
  weil ihr Cookie-Login serverseitig weg ist. Solange Phase D diese
  Repos nicht ersetzt hat, wäre ein echter Logout daraus fatal —
  der erste TourBloc-Load nach Keycloak-Login würde die Session
  sofort wieder wegwerfen. Die legitime SessionExpired-Quelle bleibt
  der Provider-Stream (Refresh-Failure).
- CarSelectionPage hat jetzt durchgehend eine AppBar (vorher nur
  im 'wechseln'-Modus) plus ein Account-Popup oben rechts mit
  Personalnummer + roter Abmelden-Aktion. Der Drawer ist sonst
  nur an Home, und solange Cars-Loading per 401 blockt, kommt der
  User ohne Pre-Home-Logout nicht raus.
2026-05-15 11:33:34 +02:00
f074d53f3d Phase B+1: Bootstrap-Splash + Logout im Drawer
- AuthBootstrapping als neuer Initial-State im AuthBloc. Beim Cold-Start
  bleibt die App im Splash, bis restoreSession entweder Authenticated
  oder Unauthenticated emittiert — kein sichtbarer LoginPage-Flash mehr
  für Nutzer mit gespeicherter Session.
- LoginEnforcer rendert für AuthBootstrapping ein eigenes Splash-Widget
  mit Logo + Spinner, für Unauthenticated weiterhin die LoginPage.
- AuthBloc._handleRestore emittiert Unauthenticated explizit, wenn
  restoreSession false liefert oder wirft — sonst bliebe der Bootstrap-
  State hängen.
- HomeAppDrawer zeigt jetzt displayName + Personalnummer aus dem
  Authenticated-State im Header und bekommt einen Abmelden-Eintrag
  unten (rot, Confirm-Dialog), der LogoutRequested feuert. Der
  Provider löscht den Refresh-Token aus der Secure Storage und der
  LoginEnforcer routet automatisch zurück auf die LoginPage.
2026-05-15 11:21:57 +02:00
cb22fff407 Phase B Fixes: AppAuth-Stored-State + LAN-Cleartext + force prompt=login
Drei zusammenhängende Korrekturen, die den OIDC-Flow auf realen Geräten
durchgehen lassen:

1. taskAffinity="" raus aus MainActivity — sonst landet die
   RedirectUriReceiverActivity beim Rücksprung aus Samsung Internet
   Custom Tabs (FLAG_ACTIVITY_NEW_TASK) in einem zweiten Task und
   zweitem App-Prozess, AppAuth findet seinen in-memory PKCE-State
   nicht und meldet 'No stored state - unable to handle response'.

2. network_security_config.xml: base-config cleartextTrafficPermitted
   statt einzelner localhost/10.0.2.2-Domains. Notwendig für Tests
   gegen die LAN-IP des Dev-Macs (z.B. 192.168.x.x); AndroidConfig kann
   keine IP-Wildcards. Klar als Dev-only markiert.

3. promptValues=['login'] auf der AuthorizationTokenRequest — verhindert
   den Instant-SSO-Cookie-Redirect-Race, bei dem Chrome Custom Tabs
   schliesst, bevor der Redirect-Intent ankommt; AppAuth wuerde sonst
   'User cancelled flow' melden, obwohl der Nutzer nicht abgebrochen
   hat. UX-mässig auch gewollt: jeder Login frisch (Account-Wechsel
   am gleichen Geraet ist denkbar), Restore laeuft über den Refresh-
   Token aus der Secure Storage.
2026-05-15 11:16:18 +02:00
08824290ff Phase B: Token-Provider und AuthBloc robust gegen Storage-Plugin-Fehler
Beobachtung: Nach 'flutter_secure_storage' frisch dazugepackt ohne
Cold-Restart kam eine MissingPluginException auf dem AuthBloc-Stream
durch (read auf channel plugins.it_nomads.com/flutter_secure_storage)
und hat den ganzen Bloc-Event-Loop mitgerissen.

Fix:
- KeycloakOidcTokenProvider.restoreSession / _persistRefreshToken /
  logout fangen Plugin-Exceptions ab und loggen sie über debugPrint,
  statt sie hochzureichen. Restore-Pfad endet sauber mit 'kein Restore
  möglich', Login-Pfad hält den Token in Memory weiter.
- AuthBloc._handleRestore mit eigener try/catch als zweite Schutzschicht
  für jeden anderen Fehler aus dem Provider.

Bestehender Cold-Restart-Workaround (App stoppen + flutter run) für die
ursprüngliche MissingPluginException bleibt natürlich nötig — diese
Änderung sorgt nur dafür, dass künftige Storage-Probleme (Keychain
zerschossen, Restore-Backup, …) nicht die Auth komplett killen.
2026-05-14 23:04:12 +02:00
6d7e58fc0f Phase B: Keycloak OIDC (PKCE) statt Cookie-Session-Login
App-Code:
- KeycloakOidcTokenProvider: PKCE-Login via flutter_appauth, Refresh via
  Refresh-Token aus flutter_secure_storage, Session-Restore beim
  App-Start, Logout.
- AuthSessionEvent als Provider→Bloc-Brücke (LoggedIn/LoggedOut/
  SessionExpired) auf einem Broadcast-Stream.
- AuthBloc komplett umgebaut: nimmt jetzt den KeycloakOidcTokenProvider
  statt UserInfoService, mappt eingehende Provider-Events auf eigene
  Zustände. Authenticated.fromClaims() liest personalnummer + Name aus
  dem ID-Token-Payload.
- LoginPage: kein Browser+Deep-Link mehr — Button feuert
  LoginRequested, der Provider übernimmt den restlichen Flow.
- network_locator: produktiver KeycloakOidcTokenProvider, doppelt
  registriert (KeycloakOidcTokenProvider für AuthBloc,
  AuthTokenProvider für Interceptor).
- Auth-State trägt zusätzlich personalnummer/displayName/email; das
  Legacy-User-Objekt + sessionId bleiben temporär drin, damit die
  alten ERPframe-Services (Phase D) noch kompilieren.

Plattform-Setup:
- Android: appAuthRedirectScheme=holzleitner in build.gradle.kts,
  NetworkSecurityConfig erlaubt HTTP zu localhost/10.0.2.2/127.0.0.1.
- iOS: holzleitner als URL-Scheme im Info.plist, ATS-Ausnahme für
  localhost (HTTP-Keycloak im Dev-Setup).

Out of scope:
- Keine echte App-Run-Smoke — kommt mit dem User-Test.
- iOS-pod-install läuft beim ersten 'flutter run ios' automatisch.
- Old ERPframe-Services bleiben aktiv und werfen ab jetzt 401 (kein
  Cookie-Session-Token mehr) — wird in Phase D entfernt.
2026-05-14 22:59:36 +02:00
8cf4045e44 Phase A: generierter Dart-Client + DI-Foundation für Rust-Backend
OpenAPI-Generator-Setup:
- tool/generate_api_client.sh: Direkter Aufruf der openapi-generator-cli.jar
  (Java-CLI statt Dart-build_runner-Integration — vermeidet die
  analyzer-/source_gen-Version-Hölle mit json_serializable)
- tool/fetch_openapi_generator.sh: lädt die JAR (29 MB) nach (gitignored)
- openapi/holzleitner.json: Snapshot der Backend-Spec für reproduzierbare
  Generation
- packages/holzleitner_api/: generiertes Dart-Sub-Package (built_value +
  dio), per path-dep im Haupt-pubspec eingehängt

Netzwerk-Layer (lib/data/network/):
- BackendConfig: API- und Keycloak-Endpoints für Local-Dev (localhost
  wegen Keycloak-iss-Claim).
- AuthTokenProvider-Schnittstelle.
- DevPasswordGrantTokenProvider: Phase-A-Provider via Keycloak
  password-grant, Token-Caching mit Expiry-Check (Phase B ersetzt das
  durch flutter_appauth PKCE).
- HolzleitnerAuthInterceptor: dynamischer Bearer-Inject pro Request.
- HolzleitnerApiFactory: baut die generierte HolzleitnerApi-Klasse
  mit unserem Interceptor statt der vier Default-Auth-Interceptors.
- network_locator.registerNetworking(): get_it-Setup, in main() vor
  runApp() aufgerufen.

Clean-Arch-Scaffolding (lib/data/, lib/domain/):
- Verzeichnisstruktur für Phase C+D angelegt (mapper/, repository/,
  entity/, repository/) — befüllt sich in den Folge-Phasen.

Smoke-Test:
- tool/smoke_test_api.dart ruft /health (ungeschützt) und /me/cars
  (mit Bearer) via generiertem Client — grün gegen lokales Backend.
2026-05-14 22:44:51 +02:00
456fb59668 Phasenbasierte Lieferübersicht + Beladen-Flow, plus Migrationsplan für Rust-Backend
UI-Restructuring:
- TabBar in scan_page durch dedizierte Phasen ersetzt: Sortieren / Beladen / Ausliefern
- PhaseBloc + PhaseService leiten Phase aus Tour-/Item-States ab
- DeliverySelectionPage (ab 2 Autos) und DeliverySortPage als eigene Flows
- LoadingOverviewPage / LoadingCustomerPage für die Beladephase
- PhaseStepper-Widget im Home für Phasen-Anzeige
- Lager-Differenzierung (Standardlager 0 vs. Außenlager) via WarehouseBadge

Process-Stubs:
- ProcessRepository für Hold/Cancel/Sort/Assign-Flows (stub, bereit für Backend-Anbindung)

Doku:
- docs/BACKEND_MIGRATION.md: Phasenplan für Umstellung auf das neue
  Rust-Backend (OpenAPI-Generator, Keycloak OIDC, Clean-Arch-Layering)
2026-05-14 22:27:56 +02:00
582 changed files with 52120 additions and 10649 deletions

9
.gitignore vendored
View File

@ -43,3 +43,12 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# OpenAPI-Generator
# - JAR wird per tool/fetch_openapi_generator.sh nachgeladen (nicht im Repo)
# - Generierter Code in packages/holzleitner_api/ wird commited,
# nur Build-Outputs werden ignoriert
tool/openapi-generator-cli.jar
packages/holzleitner_api/.dart_tool/
packages/holzleitner_api/build/
packages/holzleitner_api/pubspec.lock

View File

@ -28,6 +28,12 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
// flutter_appauth liest diesen Placeholder und registriert
// dynamisch eine RedirectUriReceiverActivity, die den
// OIDC-Callback im Custom-Scheme `holzleitner://` abfängt.
// Muss mit der RedirectUri im Keycloak-Client matchen.
manifestPlaceholders["appAuthRedirectScheme"] = "holzleitner"
}
buildTypes {

View File

@ -17,16 +17,26 @@
<application
android:label="hl_lieferservice"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!--
taskAffinity bewusst NICHT auf "" gesetzt (war Flutter-
Default-Scaffold). Grund: flutter_appauth's
RedirectUriReceiverActivity erbt die Default-Affinität
(= Package-Name). Wenn MainActivity affinity="" hätte,
würde der Custom-Tab-Redirect mit FLAG_ACTIVITY_NEW_TASK
in einem zweiten Task + Prozess landen, AppAuth verliert
seinen in-memory PKCE-State und meldet
"No stored state - unable to handle response".
-->
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
@ -39,15 +49,13 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="myapp"
android:host="callback" />
</intent-filter>
<!--
Der OIDC-Redirect (holzleitner://oauth2redirect) wird NICHT
hier abgefangen, sondern in der von flutter_appauth zur
Laufzeit eingehängten RedirectUriReceiverActivity (siehe
merged AndroidManifest und manifestPlaceholders in
build.gradle.kts).
-->
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
DEV-Konfiguration: erlaubt HTTP-Klartext-Traffic zu allen Hosts.
Begründung: das Rust-Backend + Keycloak laufen während der
Entwicklung als Docker-Container auf dem Mac und werden je nach
Test-Setup unter unterschiedlichen Adressen erreicht
(localhost / 10.0.2.2 / LAN-IP). Android-NetworkSecurityConfig kann
keine IP-Range-Wildcards (192.168.*.*), deshalb hier base-config
statt einzelner domain-config-Einträge.
*** Vor jedem Produktiv-/Beta-Release entfernen ***
Für Produktion: cleartextTrafficPermitted="false" als Default lassen
und nur die echten HTTPS-Backend-Hosts whitelisten.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>

224
docs/BACKEND_MIGRATION.md Normal file
View File

@ -0,0 +1,224 @@
# Backend-Migration: ERPframe → Rust-Backend
Stand: 2026-05-14
## Ziel
Die Holzleitner-Lieferservice-App wird vom alten ERPframe/DOCUframe-Backend
auf das neue Rust-Backend (`/Users/dennis/Desktop/Arbeit/Holzleitner-Backend`)
umgestellt. Auf Client-Seite kommt Clean Architecture konsequent durch:
data-Layer (generierte API-DTOs), domain-Layer (App-Entities), Mapper,
Repositories, Blocs.
## Entscheidungen
| Entscheidung | Wahl | Begründung |
|---|---|---|
| API-Client | **OpenAPI-Generator** (`dart` mit `dio` als Provider) | Wir haben sauberes OpenAPI im Backend; handschriftliche Services wären 1000 Zeilen Drift-Risiko. |
| Discount/Payment-Features | **Feature-Flag mit Stubs**, UI-Pfade bleiben kompilierbar | Diese Features existieren im neuen Backend nicht. Stubs werfen `UnsupportedFeatureException`. UI-Aufrufer können sie defensiv behandeln. Spätere Reaktivierung möglich, wenn Kunde bestätigt. |
| Offline-Scan-Queue | **Später**, erst Online-Pfad | Erstmal sauberer Online-Flow. Outbox-Pattern kommt als separate Iteration, wenn das Online-Verhalten sitzt. |
| Phasenfolge | A → B → C → D → E → F | Jede Phase einzeln smoke-getestet, bevor die nächste beginnt. |
## Backend-Endpunkte, die wir treffen
Aus dem fertigen Rust-Backend (Stand 2026-05-14):
| HTTP | Pfad | Use Case |
|---|---|---|
| GET | `/me/tours/today` | Liste der heutigen Touren des angemeldeten Fahrers |
| GET | `/tours/{id}` | Tour-Aggregat (eine Lieferung pro Roundtrip) |
| PUT | `/tours/{id}/delivery-order` | Sortierreihenfolge schreiben |
| POST | `/scans` | Bulk-Scan-Endpoint mit `clientScanId` für Idempotenz |
| POST | `/deliveries/{id}/hold\|resume\|cancel\|complete` | Lifecycle-Übergänge |
| PUT | `/deliveries/{id}/assigned-car` | Fahrzeug-Zuordnung |
| POST | `/deliveries/{id}/notes` | Notiz anlegen (Text/Bild-Anhang) |
| GET | `/me/cars[?includeInactive=true]` | Fahrzeuge des Fahrers |
| POST | `/me/cars` | Fahrzeug anlegen |
| PATCH | `/me/cars/{id}` | Fahrzeug ändern/deaktivieren |
| GET | `/accounts/{personalnummer}` | Account-Stammdaten |
Auth: Keycloak OIDC, JWT Bearer, OIDC-Discovery via
`http://localhost:8080/realms/holzleitner/.well-known/openid-configuration`,
Audience `holzleitner-api`, Client `holzleitner-app` (public + PKCE).
## Modell-Alignment
Die App-Modelle (`lib/model/*`) entsprechen nicht den Backend-Modellen.
Wichtigste Differenzen, die der Mapper auflösen muss:
| App heute | Backend |
|---|---|
| `Article.amount`, `scannedAmount`, `isParent`, `components[]` | `DeliveryItem.requiredQuantity`, `scanState`, flache Liste, `komponentenArtikelNr?` |
| `DeliveryState.{ongoing, finished, onhold, canceled}` | `DeliveryState.{active, completed, held, canceled}` |
| `Car { int id, String plate }` | `Car { UUID id, i64 accountId, String plate, bool active }` |
| `Note { int id, String content }` | `DeliveryNote { UUID, text?, imageAttachment?, authorPersonalnummer, authorCarId?, createdAt }` |
| `Tour { Driver driver, deliveriesPerCar }` | `TourDetails { Tour, Vec<DeliveryWithItems>, customers, articles, warehouses, notes }` |
| `Discount { article, note, noteId }` | — kein Backend-Pendant |
| `Payment { id, description, shortcode }` | — kein Backend-Pendant |
Stücklisten-Komponenten als eigene Entity entfallen. Die Hierarchie kann
die UI aus `komponentenArtikelNr` rekonstruieren, wenn gebraucht.
## Was als Feature-Flag stubbt
Discount- und Payment-Pfade bleiben in der UI sichtbar, aber:
- `DiscountRepository.add/remove/update``throw UnsupportedFeatureException("discounts")`
- `PaymentRepository.list/select` → liefert `[]` bzw. `null`
- Aufrufende Stellen (z. B. `TourBloc`) fangen die Exception, zeigen
Snackbar "Funktion nicht verfügbar" und machen weiter.
Konfigurierbar über `FeatureFlags.discountsEnabled = false` und
`FeatureFlags.paymentsEnabled = false` (Default beides `false`).
## Phasen
### Phase A — Foundation
- `dio: ^5` und `dio_smart_retry` (optional) in `pubspec.yaml`.
- `openapi_generator` als `dev_dependency`; Generator-Aufruf in
`tool/generate_api_client.sh` (oder build_runner) verdrahten.
- OpenAPI-Spec lokal aus dem laufenden Backend ziehen
(`curl http://localhost:3000/openapi.json -o openapi/holzleitner.json`),
einchecken (zur Reproduzierbarkeit), generierten Code unter
`lib/data/api/` ablegen, gitignoren.
- `AuthInterceptor` für `dio`: Bearer-Header einhängen, bei 401 versuchen
zu refreshen, sonst Logout-Event feuern.
- `get_it`-Registrierungen für `Dio`, generierte API-Klassen,
Repositories.
Smoke: `GET /health` und `GET /me/cars` (mit hartkodiertem Token aus
Keycloak) via generiertem Client erreichbar.
### Phase B — Auth (Keycloak OIDC)
- `flutter_appauth: ^7` für PKCE-Flow.
- `flutter_secure_storage` für Refresh-Token.
- `KeycloakAuthService` mit `login()`, `logout()`, `refresh()`,
`currentAccessToken()`.
- `LoginPage` umbauen: kein Browser-Redirect + Deep-Link, sondern
PKCE-Flow via `flutter_appauth`. Bei Erfolg: Tokens in Secure Storage,
AuthBloc-Event `SetAuthenticatedEvent`.
- `AuthBloc.Authenticated` State trägt jetzt
`{ accessToken, refreshToken, idTokenClaims }` statt `sessionId`.
- `AuthInterceptor` aus Phase A nutzt diesen State.
Smoke: Login → Token landet im Secure Storage → `GET /me/cars` über
Interceptor klappt.
### Phase C — Domain & Mapper
- Generierte DTOs (`lib/data/api/model/`) bleiben unverändert.
- Domain-Entities in `lib/domain/entity/` definieren — analog zu heute,
aber an Backend-Felder angepasst (Enum-Werte, UUID-IDs etc.).
- Mapper unter `lib/data/mapper/` pro Entity:
`TourMapper.fromDto(TourDetailsDto) -> Tour`.
- `lib/model/*` und `lib/dto/*` werden **gelöscht**, sobald alle
Aufrufer auf die neuen domain-Entities umgestellt sind. Bis dahin
laufen beide parallel.
Kein UI-Test nötig — diese Phase ist reines Refactoring.
### Phase D — Repositories
- Pro Repository abstraktes Interface in `lib/domain/repository/`,
konkrete Implementierung in `lib/data/repository/`.
- Implementierungen rufen den generierten API-Client auf und mappen
DTO ↔ Entity.
- Neu/umgestellt:
- `TourRepository``GET /me/tours/today` + `GET /tours/{id}` +
`PUT /tours/{id}/delivery-order`
- `DeliveryRepository``POST /deliveries/{id}/{hold,resume,cancel,complete}`,
`PUT /deliveries/{id}/assigned-car`
- `ScanRepository``POST /scans` (bulk)
- `NoteRepository``POST /deliveries/{id}/notes`
- `CarsRepository``GET/POST/PATCH /me/cars`
- `ProcessRepository` (heute Stubs) → bekommt echte Calls
- Wegfall:
- `DiscountRepository` → Stub mit `UnsupportedFeatureException`
- Payment-Methoden in `TourRepository` → Stub `[]`
Smoke: Login → `loadTourOfToday()` → Tour kommt durch.
### Phase E — Blocs
- `TourBloc`: an neues `Tour`-Aggregat anpassen, Discount/Payment-Events
hinter Feature-Flag.
- `CarsBloc`: nutzt neue `CarsRepository`-Signaturen (UUID statt int,
`active` statt delete).
- `CarSelectBloc`: speichert Car-UUID statt `int` in
SharedPreferences (Migration der bestehenden gespeicherten Auswahl:
bei UUID-Parse-Fehler einfach `null` und neu auswählen lassen).
- `AuthBloc`: bereits in Phase B angepasst, hier nur noch
Konsistenz-Check.
- `PhaseBloc`: bleibt UI-seitig, konsumiert das neue Aggregat.
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
Manuell durchklicken:
1. Login (Keycloak)
2. Fahrzeugauswahl
3. Sortier-Phase (Reihenfolge per Drag & Drop, Persistieren)
4. Beladen-Phase (Scan, Hold, Remove)
5. Auslieferung-Phase (Complete, Notizen, Cancel)
6. Logout
Bei jedem Schritt: Backend-Logs prüfen (richtige Endpoints gehen rein,
keine 4xx/5xx).
## Was die App nach der Migration *nicht* mehr kann
- Rabatte (Discount-Pfade als Stub, UI zeigt Snackbar)
- Zahlungsarten-Auswahl (Payment-Pfade als Stub)
- Lieferung-spezifische ERP-Optionen (`DeliveryOption`) — fielen
ohnehin durch das Modell raus
Diese Features kommen zurück, sobald sie im Backend modelliert sind.
## Was die App nach der Migration *neu* kann
- Idempotente Bulk-Scans (Offline-Wiederholbarkeit auf Protokoll-Ebene
schon vorbereitet)
- Saubere Audit-Spuren (`actorCarId` an Scans + Notizen)
- Fahrzeug-Soft-Delete (statt Hardes Löschen)
- Server-vergebene `tour_date` (statt App-Datum)
## Out of Scope dieser Migration
- Offline-Queue für Scans (kommt später)
- Object-Storage für Bilder (heute schicken wir Strings, später
pre-signed URLs)
- Cars-Verwaltung von ERPframe migrieren — Fahrzeuge werden neu
angelegt
- ERP-Sync (POST /sync/tour) — das ist DOCUcontrol-Seite, nicht App

View File

@ -29,10 +29,29 @@
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<!-- Altes Schema des Cookie-Login-Deep-Links (wird mit
Phase D entfernt, sobald keine Aufrufer mehr da sind). -->
<string>myapp</string>
<!-- OIDC-Redirect für flutter_appauth → Keycloak-Client
holzleitner-app, RedirectURI holzleitner://oauth2redirect. -->
<string>holzleitner</string>
</array>
</dict>
</array>
<!-- ATS-Ausnahme für Local-Dev: HTTP-Zugriff auf localhost (Keycloak
auf 8080, Rust-Backend auf 3000). In Produktion entfernen sobald
Backend per HTTPS erreichbar ist. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>

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:hl_lieferservice/bloc/app_events.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> {
AppBloc() : super(AppInitial()) {
on<AppLoadConfig>(_loadConfig);
}
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.",
),
);
}
AppBloc() : super(const AppInitial()) {
on<AppLoadConfig>((event, emit) {
emit(const AppConfigLoading());
emit(const AppConfigLoaded());
});
}
}

View File

@ -1,16 +1,27 @@
import '../services/erpframe.dart';
abstract class AppState {}
class AppInitial extends AppState {}
class AppConfigLoading extends AppState {}
class AppConfigLoaded extends AppState {
LocalDocuFrameConfiguration config;
AppConfigLoaded({required this.config});
/// 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();
}
class AppConfigLoadingFailed extends AppState {
String message;
AppConfigLoadingFailed({required this.message});
}
class AppInitial extends AppState {
const AppInitial();
}
class AppConfigLoading extends AppState {
const AppConfigLoading();
}
class AppConfigLoaded extends AppState {
const AppConfigLoaded();
}
class AppConfigLoadingFailed extends AppState {
const AppConfigLoadingFailed({required this.message});
final String 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
}
}
}

0
lib/data/mapper/.gitkeep Normal file
View File

View File

@ -0,0 +1,23 @@
import 'package:holzleitner_api/holzleitner_api.dart' as api;
import 'package:hl_lieferservice/domain/entity/car.dart';
/// Mapper zwischen dem generierten API-DTO `api.Car` (built_value)
/// und der Domain-Entity [Car].
///
/// Nur Read-Mapper hier — Create/Update geht über die Request-DTOs
/// `api.CreateCarRequest` / `api.UpdateCarRequest`, die die Repository-
/// Impl direkt zusammenbaut.
extension ApiCarMapper on api.Car {
Car toDomain() => Car(
id: id,
accountId: accountId,
plate: plate,
active: active,
);
}
/// Liste-Variante als Convenience.
extension ApiCarIterableMapper on Iterable<api.Car> {
List<Car> toDomainList() => map((c) => c.toDomain()).toList(growable: false);
}

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

@ -0,0 +1,26 @@
/// Events, die der `KeycloakOidcTokenProvider` über seinen
/// Broadcast-Stream auswirft. Der AuthBloc abonniert diesen Stream
/// und reagiert mit eigenen Zustands-Übergängen.
///
/// Bewusst eigene Events (statt direkter Bloc-Aufrufe), damit der
/// Token-Provider keine Abhängigkeit auf die Bloc-Schicht braucht.
sealed class AuthSessionEvent {
const AuthSessionEvent();
}
/// Erfolgreicher Login (frisch oder restauriert).
final class AuthLoggedIn extends AuthSessionEvent {
const AuthLoggedIn(this.claims);
final Map<String, dynamic> claims;
}
/// Sauberer Logout durch den Nutzer.
final class AuthLoggedOut extends AuthSessionEvent {
const AuthLoggedOut();
}
/// Refresh fehlgeschlagen oder Server lehnt Token ab — die App muss
/// zurück zur Login-Page.
final class AuthSessionExpired extends AuthSessionEvent {
const AuthSessionExpired();
}

View File

@ -0,0 +1,9 @@
/// Schnittstelle, über die HTTP-Interceptors einen aktuellen
/// Access-Token beziehen. Phase A liefert das eine simple
/// Password-Grant-Implementation; Phase B (Keycloak OIDC mit
/// flutter_appauth) ersetzt sie durch eine echte PKCE-/Refresh-fähige.
abstract interface class AuthTokenProvider {
/// Liefert einen aktuell gültigen Access-Token oder `null`, wenn
/// keine Session aktiv ist. Darf bei Bedarf einen Refresh anstoßen.
Future<String?> currentAccessToken();
}

View File

@ -0,0 +1,96 @@
/// Endpoint-Konfiguration für das Rust-Backend.
///
/// Diese Übergangs-Konfiguration für die Backend-Migration wird in
/// Phase D durch eine umfassendere Konfigurations-Ablösung verfeinert
/// (Build-Time-Flavor pro Stage etc.).
///
/// **Werte für lokale Entwicklung:**
/// * iOS-Simulator + macOS-Host: `http://localhost:...`
/// * Android-Emulator: `http://10.0.2.2:...`
/// * Echtes Gerät im LAN: `http://<host-IP>:...`
///
/// Default ist iOS-Simulator-tauglich; für Android-Build vor dem
/// Compile umstellen oder per Build-Flag injizieren.
class BackendConfig {
const BackendConfig({
required this.apiBaseUrl,
required this.keycloakIssuerUrl,
required this.keycloakClientId,
required this.keycloakRedirectUrl,
});
/// Basis-URL der Rust-API (kein abschließender Slash).
final String apiBaseUrl;
/// Realm-Issuer ohne `/.well-known/...`-Suffix —
/// `flutter_appauth` hängt das selbst an für die Discovery.
/// Beispiel: `http://localhost:8080/realms/holzleitner`.
///
/// **Achtung:** Keycloak prägt das `iss`-Claim aus dem Hostnamen
/// dieser URL. Das Backend erwartet exakt diesen String als
/// `KEYCLOAK_ISSUER_URL`. Mismatch → 401 mit `invalid issuer`.
final String keycloakIssuerUrl;
/// Token-Endpoint des Realms — abgeleitet aus dem Issuer.
String get keycloakTokenEndpoint =>
'$keycloakIssuerUrl/protocol/openid-connect/token';
/// Public-Client-Id (entspricht der `aud` im Backend-Token).
final String keycloakClientId;
/// Custom-Scheme-Redirect, das in Keycloak als
/// `holzleitner://oauth2redirect` whitelisted ist. Muss mit dem
/// `appAuthRedirectScheme` in `android/app/build.gradle.kts` und
/// dem `CFBundleURLSchemes`-Eintrag in `ios/Runner/Info.plist`
/// matchen.
final String keycloakRedirectUrl;
/// Default-Konfiguration für lokale Entwicklung gegen das
/// Docker-Compose-Setup (Postgres + Keycloak + Backend).
static const BackendConfig localDev = BackendConfig(
apiBaseUrl: 'http://192.168.0.138:3000',
keycloakIssuerUrl: 'http://192.168.0.138:8080/realms/holzleitner',
keycloakClientId: 'holzleitner-app',
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

@ -0,0 +1,73 @@
import 'package:dio/dio.dart';
import 'auth_token_provider.dart';
/// **Nur für Phase A / Smoke-Tests.** Spricht direkt den Keycloak-
/// Token-Endpoint mit `grant_type=password` an — bequem für
/// Dev-Setups, in Produktion komplett ungeeignet (kein PKCE, keine
/// Userinteraktion, Credentials in der App).
///
/// In Phase B ersetzt diese Klasse die KeycloakOidcTokenProvider-
/// Implementierung, die auf flutter_appauth basiert.
///
/// Cached den Token im Speicher und erneuert ihn, sobald die
/// Restlaufzeit unter 30 s fällt — kein eingebauter Refresh-Token-
/// Flow, dafür einfach.
class DevPasswordGrantTokenProvider implements AuthTokenProvider {
DevPasswordGrantTokenProvider({
required this.tokenEndpoint,
required this.clientId,
required this.username,
required this.password,
Dio? dio,
}) : _dio = dio ?? Dio();
final Dio _dio;
final String tokenEndpoint;
final String clientId;
final String username;
final String password;
String? _cachedToken;
DateTime? _expiresAt;
@override
Future<String?> currentAccessToken() async {
final now = DateTime.now();
final cached = _cachedToken;
final expiresAt = _expiresAt;
if (cached != null &&
expiresAt != null &&
expiresAt.isAfter(now.add(const Duration(seconds: 30)))) {
return cached;
}
final response = await _dio.post<Map<String, dynamic>>(
tokenEndpoint,
data: {
'grant_type': 'password',
'client_id': clientId,
'username': username,
'password': password,
'scope': 'openid',
},
options: Options(contentType: Headers.formUrlEncodedContentType),
);
final data = response.data;
if (data == null) {
throw StateError('Keycloak token endpoint lieferte leeren Body');
}
final token = data['access_token'] as String?;
final expiresIn = data['expires_in'] as int?;
if (token == null || expiresIn == null) {
throw StateError(
'Keycloak token endpoint lieferte unerwartete Antwort: $data',
);
}
_cachedToken = token;
_expiresAt = now.add(Duration(seconds: expiresIn));
return token;
}
}

View File

@ -0,0 +1,35 @@
import 'package:dio/dio.dart';
import 'package:holzleitner_api/holzleitner_api.dart';
import 'auth_token_provider.dart';
import 'backend_config.dart';
import 'holzleitner_auth_interceptor.dart';
/// Baut den generierten `HolzleitnerApi`-Client mit:
/// * eigener `Dio`-Instanz (Base-URL + Timeouts aus [BackendConfig]),
/// * **ohne** die vier Default-Auth-Interceptors des Generators —
/// stattdessen wird unser [HolzleitnerAuthInterceptor] gehängt.
///
/// Auf diese Weise bleibt die Token-Quelle pluggable
/// ([AuthTokenProvider]), und Phase B (Keycloak OIDC) kann die
/// Provider-Implementierung austauschen, ohne den Rest des Codes
/// anzufassen.
HolzleitnerApi buildHolzleitnerApi({
required BackendConfig config,
required AuthTokenProvider tokenProvider,
}) {
final dio = Dio(
BaseOptions(
baseUrl: config.apiBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
headers: const {'Accept': 'application/json'},
),
);
return HolzleitnerApi(
dio: dio,
interceptors: [HolzleitnerAuthInterceptor(tokenProvider)],
);
}

View File

@ -0,0 +1,33 @@
import 'package:dio/dio.dart';
import 'auth_token_provider.dart';
/// Dio-Interceptor, der pro Request den aktuellen Access-Token vom
/// `AuthTokenProvider` zieht und als `Authorization: Bearer …`-Header
/// anhängt. Provider-Fehler werden geloggt, der Request läuft trotzdem
/// weiter — das Backend antwortet dann mit 401, was der reguläre
/// Error-Handling-Pfad behandelt.
class HolzleitnerAuthInterceptor extends Interceptor {
HolzleitnerAuthInterceptor(this._tokenProvider);
final AuthTokenProvider _tokenProvider;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
try {
final token = await _tokenProvider.currentAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
} catch (e, stack) {
// TODO Phase B: hier ein strukturiertes Logging-Framework
// einhängen statt print.
// ignore: avoid_print
print('[HolzleitnerAuthInterceptor] Token-Provider hat geworfen: $e\n$stack');
}
handler.next(options);
}
}

View File

@ -0,0 +1,294 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'auth_session_event.dart';
import 'auth_token_provider.dart';
import 'backend_config.dart';
/// OIDC-Token-Provider gegen Keycloak (PKCE Authorization Code Flow).
///
/// Verantwortlich für:
/// * **Login** über `flutter_appauth` (Browser-Tab → Keycloak →
/// RedirectURI `holzleitner://oauth2redirect`).
/// * **Token-Refresh** mit dem persistierten Refresh-Token.
/// * **Session-Restore** beim App-Start (Refresh-Token aus Secure
/// Storage einlesen + sofortigen Refresh anstoßen).
/// * **Logout**: lokale Token droppen + Secure Storage säubern.
///
/// Access-Token und ID-Token-Claims liegen im Speicher (flüchtig),
/// der Refresh-Token wird in der Plattform-Secure-Storage abgelegt
/// (Keychain / EncryptedSharedPreferences).
///
/// Events werden über [events] gebroadcastet — der AuthBloc abonniert
/// und reagiert. Diese Indirektion vermeidet eine Abhängigkeit auf die
/// Bloc-Schicht.
class KeycloakOidcTokenProvider implements AuthTokenProvider {
KeycloakOidcTokenProvider({
required BackendConfig config,
required this.issuerUrl,
required this.redirectUrl,
FlutterAppAuth? appAuth,
FlutterSecureStorage? storage,
}) : _config = config,
_appAuth = appAuth ?? const FlutterAppAuth(),
_storage = storage ?? const FlutterSecureStorage();
final BackendConfig _config;
final FlutterAppAuth _appAuth;
final FlutterSecureStorage _storage;
/// Vollständige Issuer-URL ohne `/.well-known/...`-Suffix.
/// Beispiel: `http://localhost:8080/realms/holzleitner`.
final String issuerUrl;
/// Redirect-URI, die im Keycloak-Client erlaubt ist und auf das
/// Custom-Scheme der App matcht. Beispiel:
/// `holzleitner://oauth2redirect`.
final String redirectUrl;
String? _accessToken;
DateTime? _expiresAt;
String? _refreshToken;
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 =
StreamController<AuthSessionEvent>.broadcast();
/// Stream der Auth-Events. Wird vom AuthBloc abonniert.
Stream<AuthSessionEvent> get events => _events.stream;
/// Discovery-URL für `flutter_appauth` — wird intern auch geprüft,
/// bevor PKCE losläuft.
String get _discoveryUrl => '$issuerUrl/.well-known/openid-configuration';
/// ID-Token-Claims der aktuellen Session, oder `null` wenn nicht
/// angemeldet.
Map<String, dynamic>? get idTokenClaims => _idTokenClaims;
bool get isAuthenticated => _accessToken != null;
/// Triggert den PKCE-Login-Flow. Wirft, wenn der User abbricht oder
/// Keycloak einen Fehler liefert.
Future<void> login() async {
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
_config.keycloakClientId,
redirectUrl,
discoveryUrl: _discoveryUrl,
scopes: const ['openid', 'profile'],
// Lokales Dev-Setup hat HTTP-Keycloak — der Default-iOS-Browser
// würde sonst abbrechen. In Produktion (HTTPS) ist das ein
// No-Op und kann bleiben.
allowInsecureConnections: true,
// Wichtig auf Android: ohne `prompt=login` würde Keycloak bei
// bestehender SSO-Session sofort 302 nach holzleitner://...
// antworten, Chrome schließt den Custom Tab dabei so schnell,
// dass der Redirect-Intent unsere RedirectUriReceiverActivity
// gar nicht erreicht — AppAuth meldet stattdessen
// "User cancelled flow". Erzwingen der Login-Maske → echter
// User-Click → sauberer Intent-Dispatch.
promptValues: const ['login'],
),
);
_applyTokens(
accessToken: result.accessToken,
refreshToken: result.refreshToken,
idToken: result.idToken,
expiresAt: result.accessTokenExpirationDateTime,
);
await _persistRefreshToken();
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
}
/// Versucht, eine vorhandene Session aus der Secure Storage zu
/// reaktivieren. Liefert `true`, wenn anschließend ein gültiger
/// Access-Token verfügbar ist.
///
/// Schluckt Fehler aus dem Storage-Plugin (z. B. nicht-registriertes
/// Native-Modul nach Plugin-Update ohne Cold-Restart) — der Caller
/// landet dann sauber im "kein Restore möglich"-Pfad statt mit einer
/// MissingPluginException auf dem AuthBloc-Stream.
Future<bool> restoreSession() async {
final String? stored;
try {
stored = await _storage.read(key: _refreshTokenStorageKey);
} catch (e, st) {
debugPrint(
'restoreSession: konnte Refresh-Token nicht lesen: $e\n$st',
);
return false;
}
if (stored == null || stored.isEmpty) return false;
_refreshToken = stored;
final token = await currentAccessToken();
if (token == null) return false;
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
return true;
}
/// Bricht die Session ab — droppt alle Tokens lokal. Ein
/// serverseitiges `endSession` (Keycloak Single-Logout) machen wir
/// bewusst nicht: der Refresh-Token läuft beim Server normal aus,
/// und der ID-Token-Hint würde uns zwingen, den rohen ID-Token mit
/// in der Session zu halten.
Future<void> logout() async {
_clearSession();
try {
await _storage.delete(key: _refreshTokenStorageKey);
} catch (e) {
debugPrint('logout: Refresh-Token konnte nicht gelöscht werden: $e');
}
_events.add(const AuthLoggedOut());
}
@override
Future<String?> currentAccessToken() async {
final cached = _accessToken;
final expiresAt = _expiresAt;
final now = DateTime.now().toUtc();
// Schwellwert 30 s: Pufferzeit gegen Clock-Drift und
// mid-flight-Expiry.
if (cached != null &&
expiresAt != null &&
expiresAt.isAfter(now.add(const Duration(seconds: 30)))) {
return cached;
}
final rt = _refreshToken;
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 {
final result = await _appAuth.token(
TokenRequest(
_config.keycloakClientId,
redirectUrl,
discoveryUrl: _discoveryUrl,
refreshToken: rt,
scopes: const ['openid', 'profile'],
allowInsecureConnections: true,
),
);
_applyTokens(
accessToken: result.accessToken,
refreshToken: result.refreshToken ?? rt,
idToken: result.idToken,
expiresAt: result.accessTokenExpirationDateTime,
);
await _persistRefreshToken();
return _accessToken;
} on Exception {
// Refresh hat nicht funktioniert — Session ist tot, nicht
// wiederherstellbar. Reihenfolge bewusst: erst State leeren + Event
// feuern, DANN best-effort den Storage löschen — so kann ein
// werfendes `delete` weder das Event verschlucken noch eine Exception
// aus `currentAccessToken()` leaken.
_clearSession();
_events.add(const AuthSessionExpired());
try {
await _storage.delete(key: _refreshTokenStorageKey);
} catch (e) {
debugPrint('currentAccessToken: Refresh-Token-Delete fehlgeschlagen: $e');
}
return null;
}
}
void _applyTokens({
required String? accessToken,
required String? refreshToken,
required String? idToken,
required DateTime? expiresAt,
}) {
_accessToken = accessToken;
_refreshToken = refreshToken;
_expiresAt = expiresAt?.toUtc();
if (idToken != null) {
_idTokenClaims = _decodeJwtPayload(idToken);
}
}
Future<void> _persistRefreshToken() async {
final rt = _refreshToken;
if (rt == null) return;
try {
await _storage.write(key: _refreshTokenStorageKey, value: rt);
} catch (e) {
// Nicht fatal — Refresh-Token bleibt in-memory verfügbar,
// wir verlieren nur das Restore-Verhalten beim nächsten Start.
debugPrint('Refresh-Token konnte nicht persistiert werden: $e');
}
}
void _clearSession() {
_accessToken = null;
_expiresAt = null;
_refreshToken = null;
_idTokenClaims = null;
}
/// Dispose-Hook für Tests / Hot-Restarts.
Future<void> dispose() async {
await _events.close();
}
static const String _refreshTokenStorageKey =
'holzleitner_keycloak_refresh_token';
/// Dekodiert das Payload-Segment eines JWT. Wirft bei strukturellen
/// Auffälligkeiten — Signatur wird **nicht** geprüft (das macht der
/// Server bei jedem Request neu).
static Map<String, dynamic> _decodeJwtPayload(String token) {
final parts = token.split('.');
if (parts.length != 3) {
throw FormatException('Token-Format unerwartet: ${parts.length} Teile');
}
String payload = parts[1];
// base64url ohne Padding → wieder padden, sonst wirft base64Url.decode.
switch (payload.length % 4) {
case 0:
break;
case 2:
payload += '==';
case 3:
payload += '=';
default:
throw const FormatException('Token-Payload hat ungültige Länge');
}
final bytes = base64Url.decode(payload);
final json = utf8.decode(bytes);
final decoded = jsonDecode(json);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Token-Payload ist kein JSON-Objekt');
}
return decoded;
}
}

View File

@ -0,0 +1,41 @@
import 'package:get_it/get_it.dart';
import 'package:holzleitner_api/holzleitner_api.dart';
import 'auth_token_provider.dart';
import 'backend_config.dart';
import 'holzleitner_api_factory.dart';
import 'keycloak_oidc_token_provider.dart';
/// Registriert das HTTP-/API-Subsystem im globalen GetIt-Locator.
///
/// Aufruf bewusst nicht im AppBloc-Lifecycle, sondern in `main()` vor
/// dem `runApp` — die API-Klassen sind über die gesamte App-Lebensdauer
/// stabil.
///
/// Phase B: produktiver `KeycloakOidcTokenProvider`. Die alte
/// `DevPasswordGrantTokenProvider`-Implementation bleibt im Code (für
/// das reine dart-Smoke-Tool, siehe `tool/smoke_test_api.dart`).
void registerNetworking({
required GetIt locator,
BackendConfig config = BackendConfig.fromEnvironment,
}) {
locator.registerSingleton<BackendConfig>(config);
final provider = KeycloakOidcTokenProvider(
config: config,
issuerUrl: config.keycloakIssuerUrl,
redirectUrl: config.keycloakRedirectUrl,
);
// Doppelt registrieren: einmal unter der konkreten Klasse (für
// den AuthBloc, der Login/Logout/Restore aufruft) und einmal hinter
// dem Interface (für den HTTP-Interceptor).
locator.registerSingleton<KeycloakOidcTokenProvider>(provider);
locator.registerSingleton<AuthTokenProvider>(provider);
locator.registerSingleton<HolzleitnerApi>(
buildHolzleitnerApi(
config: config,
tokenProvider: locator<AuthTokenProvider>(),
),
);
}

View File

View File

@ -0,0 +1,96 @@
import 'package:dio/dio.dart';
import 'package:holzleitner_api/holzleitner_api.dart' as api;
import 'package:hl_lieferservice/data/mapper/car_mapper.dart';
import 'package:hl_lieferservice/domain/entity/car.dart';
import 'package:hl_lieferservice/domain/repository/cars_repository.dart';
/// Spricht den generierten Holzleitner-API-Client an und mappt die
/// `built_value`-DTOs auf die Domain-Entity.
///
/// Fehler aus dem Backend werden in [CarsRepositoryException]
/// übersetzt — der Bloc kennt die HTTP-Schicht nicht. 401 fliegt
/// ungefangen durch und wird vom übergreifenden
/// HolzleitnerAuthInterceptor-Pfad behandelt (Provider-Stream meldet
/// `AuthSessionExpired` bei Refresh-Failure).
class CarsRepositoryImpl implements CarsRepository {
CarsRepositoryImpl(this._api);
final api.HolzleitnerApi _api;
@override
Future<List<Car>> listMine({bool includeInactive = false}) async {
try {
final response = await _api.getCarsApi().listMyCars(
includeInactive: includeInactive,
);
final cars = response.data?.cars;
if (cars == null) return const [];
return cars.toDomainList();
} on DioException catch (e) {
throw CarsRepositoryException(_describe(e, 'Laden der Fahrzeuge'), e);
}
}
@override
Future<Car> create({required String plate}) async {
try {
final request =
api.CreateCarRequest((b) => b..plate = plate);
final response =
await _api.getCarsApi().createMyCar(createCarRequest: request);
final car = response.data?.car;
if (car == null) {
throw const CarsRepositoryException(
'Server lieferte leere Antwort beim Anlegen',
);
}
return car.toDomain();
} on DioException catch (e) {
throw CarsRepositoryException(_describe(e, 'Anlegen eines Fahrzeugs'), e);
}
}
@override
Future<Car> update({
required String carId,
String? plate,
bool? active,
}) async {
try {
final request = api.UpdateCarRequest((b) {
if (plate != null) b.plate = plate;
if (active != null) b.active = active;
});
final response = await _api.getCarsApi().updateMyCar(
carId: carId,
updateCarRequest: request,
);
final car = response.data?.car;
if (car == null) {
throw const CarsRepositoryException(
'Server lieferte leere Antwort beim Aktualisieren',
);
}
return car.toDomain();
} on DioException catch (e) {
throw CarsRepositoryException(
_describe(e, 'Aktualisieren eines Fahrzeugs'),
e,
);
}
}
/// Übersetzt eine DioException in eine kompakte, UI-taugliche
/// Begründung. Mehr Detail landet im Log, nicht in der Snackbar.
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 == 404) return 'Fahrzeug nicht gefunden';
return '$operation fehlgeschlagen (HTTP ${status ?? 'unbekannt'})';
}
}

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,56 @@
/// Fahrzeug eines Subunternehmer-Accounts — Domain-Entity.
///
/// Im Gegensatz zum alten `lib/model/car.dart` (int-ID,
/// ERPframe-Welt) hält die neue Entity:
/// * `id` als UUID-String (Backend-Konvention),
/// * `accountId` als Personalnummer (für Audit/Cross-Check, ist
/// redundant zur JWT-Identität aber explizit im Payload),
/// * `active`-Flag (Soft-Delete statt physisches Löschen).
class Car {
const Car({
required this.id,
required this.accountId,
required this.plate,
required this.active,
});
/// UUID des Fahrzeugs.
final String id;
/// Personalnummer des Account-Inhabers.
final int accountId;
/// Kennzeichen (z. B. "BGL-HZ 100").
final String plate;
/// Inaktive Fahrzeuge tauchen in `GET /me/cars` standardmäßig
/// nicht auf — sie bleiben aber als FK-Anker für historische
/// Audit-Einträge in der Datenbank.
final bool active;
Car copyWith({String? id, int? accountId, String? plate, bool? active}) {
return Car(
id: id ?? this.id,
accountId: accountId ?? this.accountId,
plate: plate ?? this.plate,
active: active ?? this.active,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Car &&
runtimeType == other.runtimeType &&
id == other.id &&
accountId == other.accountId &&
plate == other.plate &&
active == other.active;
@override
int get hashCode => Object.hash(id, accountId, plate, active);
@override
String toString() =>
'Car(id: $id, accountId: $accountId, plate: $plate, active: $active)';
}

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,43 @@
import 'package:hl_lieferservice/domain/entity/car.dart';
/// Port für Fahrzeug-Stammdaten des angemeldeten Fahrers.
///
/// Bewusst **kein** `personalnummer`/`teamId`-Parameter: der Account
/// wird serverseitig aus dem JWT abgeleitet, der Client muss nichts
/// mitschicken. Eine Methode, die einen Account-Filter ermöglicht,
/// ist konsequent nicht vorgesehen.
///
/// Implementierungen werfen anwendungs-spezifische Exceptions
/// (z. B. `CarsRepositoryException`) — der Aufrufer fängt sie und
/// übersetzt in UI-Zustand.
abstract interface class CarsRepository {
/// Liste der Fahrzeuge des angemeldeten Accounts.
/// `includeInactive=false` blendet deaktivierte Fahrzeuge aus
/// (Default für die App-UI).
Future<List<Car>> listMine({bool includeInactive = false});
/// Legt ein neues Fahrzeug mit dem gegebenen Kennzeichen an.
/// Wirft, wenn das Kennzeichen für den Account schon existiert.
Future<Car> create({required String plate});
/// Aktualisiert ein bestehendes Fahrzeug.
/// Beide Optional-Parameter `null` ist ein No-Op-PATCH.
/// Soft-Delete erfolgt über `update(carId: ..., active: false)`.
Future<Car> update({
required String carId,
String? plate,
bool? active,
});
}
/// Allgemeine Repository-Exception. Konkrete Implementierungen
/// können spezifischere Subtypen werfen (z. B. `CarsUnauthorized`).
class CarsRepositoryException implements Exception {
const CarsRepositoryException(this.message, [this.cause]);
final String message;
final Object? cause;
@override
String toString() => 'CarsRepositoryException: $message';
}

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,
};

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