Compare commits

...

15 Commits

Author SHA1 Message Date
f12ad5d3c0 feat(loading): Set-Kopf wird grün, wenn alle Komponenten geladen sind
Ein nicht-scanbarer Set-Kopf (Parent-Artikel) hat keinen eigenen
Scan-Status. Sobald alle scanbaren, nicht entfernten Komponenten fertig
(isDone) sind, wird der Kopf jetzt ebenfalls grün dargestellt
(effectiveDone = isDone || setParentComplete) und zeigt statt des
unterdrückten Hinweises ein grünes „Komplett geladen".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:11:21 +02:00
8b1b35b374 fix(loading): "Kein Scanvorgang notwendig"-Hinweis bei Set-Köpfen entfernen
Ein nicht-scanbarer Set-Kopf (Parent-Artikel) wird im Beladen-Screen über
seinen Komponenten gezeigt. Der Hinweis „Kein Scanvorgang notwendig" ist
dort irreführend, weil die (scanbaren) Komponenten darunter sehr wohl
gescannt werden. _ItemRow bekommt suppressScanHint; gesetzt nur für
Set-Köpfe (Artikelnummer wird von einer Komponente als parentArtikelNr
referenziert). Komponenten/echte Dienstleistungen behalten den Hinweis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:05:35 +02:00
7e345bd71b fix(articles): Mengen-Stepper beim Set-Entfernen wieder aktivieren
Ein Set kann mehrfach bestellt sein (Oberartikel-Menge = Set-Anzahl),
daher bleibt der Mengen-Stepper beim Entfernen über den Oberartikel
erhalten. Die gewählte Set-Anzahl kaskadiert PROPORTIONAL auf die
Komponenten (Stückzahl je Set × entfernte Sets, geklemmt auf Restmenge)
— funktioniert für 1:1-Mengen wie für Komponenten mit Stückzahl je Set.
Einzelne Komponenten bleiben weiterhin nicht direkt entfernbar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:04:58 +02:00
4c6bef6897 feat(delivery): Abschluss-Navigation, Mengen-Hinweis, Set-Handling
A) Nach erfolgreichem Abschluss (aktiv→completed) poppt die Detail-Page
   automatisch zurück zur Übersicht. Scaffold ist jetzt StatefulWidget
   mit BlocListener<TourBloc>; nur „gearmt", wenn die Lieferung beim
   Öffnen aktiv war → erneutes Öffnen einer fertigen Lieferung poppt nicht.

B) Step „Info": Artikelliste zeigt weiter die Ursprungsmenge
   (requiredQuantity). Bei entfernten/teilweise gutgeschriebenen Positionen
   erscheint pro Zeile ein „Menge geändert"-Hinweis + ein tappbares Banner,
   das zu Step 3 „Artikel" springt.

C) Beladen: nicht-scanbare Set-Köpfe (Parent-Komponenten) werden jetzt
   IMMER mit ihrem Set gezeigt — als Kopf in der Lagergruppe ihrer
   Komponenten statt isoliert unter „Dienstleistungen". _ItemRow leitet
   scanNotRequired aus der Artikel-Scanbarkeit ab.

D) Step „Übersicht": Wording der Zahlungsweise-Sperre bei offen==0
   präzisiert („Keine Zahlung mehr offen (bereits bezahlt)").

E) Step „Artikel": Komponenten eines Sets sind einzeln nicht mehr
   entfernbar (kein Button + Hinweis). Das Entfernen/Wiederherstellen läuft
   nur über den Oberartikel und kaskadiert auf das ganze Set (ganz oder
   gar nix). Set-Entfernen ist blockiert, solange eine Komponente noch
   nicht verladen ist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:57:52 +02:00
6d2f496700 feat(summary): Zahlungsweise-Auswahl bei offen==0 deaktivieren
Steht kein offener Betrag mehr aus (vollständig vorab bezahlt oder per
Gutschrift ausgeglichen), wird die Zahlungsmethoden-Auswahl gesperrt und
ein erklärender Hinweis angezeigt — analog zur Sperre bei bereits
abgeschlossener Lieferung.

- Offener-Betrag-Formel in Helper _openAmount(delivery, credit)
  extrahiert (Single Source; vorher nur in _PaymentSummary).
- _PaymentMethodPicker bekommt die Gutschrift und sperrt das Dropdown
  bei state != active ODER offen == 0 (editable = active && offen > 0).
- Sperr-/Info-Hinweis in wiederverwendbares _PickerHint-Widget gezogen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:10:17 +02:00
446cf73347 feat(loading): Abschluss-Dialog 'Alles gescannt - Auslieferung starten?' im Scanner
Sobald im Beladen-Scanner der letzte Pflicht-Scan erledigt ist (alle eigenen,
aktiven Lieferungen im Standardlager fertig - gleiche Bedingung wie der
'Auslieferungs-Phase starten'-Gate der Uebersicht), erscheint einmalig ein
Dialog: bestaetigt 'alles gescannt' und fragt, ob die Auslieferung starten soll.
'Auslieferung starten' -> PhaseSet(ausliefern) + Scanner schliessen; 'Spaeter'
-> nur schliessen. BlocConsumer<TourBloc>-Listener + Einmal-Flag (Reset wenn
wieder etwas offen).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:07:22 +02:00
9ec3bba047 feat(tour): Sortieren-Lade-Branch nutzt ebenfalls PhaseStepper (kein Bar-Flackern)
Auch der Lade-Zwischenzustand (state is! TourLoaded, u.a. waehrend 'Neu laden')
zeigt jetzt die PhaseStepper-AppBar statt der schlichten AppBar -> die Leiste
springt beim Reload nicht mehr kurz auf den Theme-Default zurueck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:35:00 +02:00
178edfdeed feat(tour): Sortieren-Leerzustand zeigt PhaseStepper (primary) statt schlichter AppBar
Der TourEmpty-Zweig der Sortieren-Seite rendert jetzt den PhaseStepper
(Steps, theme.primaryColor) als AppBar — die Phasen-Navigation bleibt sichtbar
und die Optik ist primary statt der Theme-Default-AppBar. Die mittige
'Keine Lieferungen heute'-Nachricht (inkl. 'Neu laden') bleibt unveraendert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:34:05 +02:00
16648240cd feat(tour): 'Neu laden'-Button im Sortieren-Leerzustand
TourEmpty-Leerzustand der Sortieren-Seite ('Keine Lieferungen heute') bekommt
einen 'Neu laden'-Button (LoadTour) — analog zum Beladen-Empty-State.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:29:49 +02:00
3a937c317d chore(branding): Launcher-Icon generiert (Android + iOS)
- flutter_launcher_icons ausgefuehrt: Android-Mipmaps 'launcher_icon' (hdpi-xxxhdpi) + kompletter iOS-AppIcon-Satz
- AndroidManifest android:icon -> @mipmap/launcher_icon (passend zur Config)
- remove_alpha_ios: true -> kein Alpha im iOS-Icon (App-Store-konform)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:11:20 +02:00
7c362549ee feat(settings): Konto-Konsole (Keycloak) oeffnen + Logout mit Bestaetigung
- 'Konto verwalten' oeffnet die Keycloak-Account-Konsole (issuer/account/) im externen Browser (Passwort/E-Mail/2FA)
- 'Ausloggen': ganze Zeile tappbar + Bestaetigungsdialog -> LogoutRequested

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:08:18 +02:00
a206636ed0 feat(tour): Tour-Neuladen ueberall + Drawer in Leer-/Ladezustaenden
- PhaseStepper: Reload-Button (RefreshTour, Spinner waehrend Refresh)
- Beladen-Empty-State: 'Neu laden'-Button (LoadTour) + Hinweis 'keine Tour verfuegbar'
- Drawer + AppBar in TourEmpty/Lade-Branches (Beladen-Uebersicht, Lieferungen auswaehlen, Sortieren) -> kein Festsitzen ohne Logout

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:08:18 +02:00
467f4b4ed2 feat(auth): Login-Timeout (10s) mit Hinweisbanner
Haengt der interaktive Login (Browser-Tab/Token-Exchange) bei Verbindungsabbruch/Issuer-Hang, bricht er nach 10s ab; LoginPage zeigt 'Einloggen nicht moeglich. Spaeter erneut versuchen.' (Unauthenticated.loginTimedOut).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:08:18 +02:00
7544760c34 chore(config): Frontend auf Prod (192.168.1.9) + Dev-Profile entfernt
- backend_config: einziges Profil 'prod' (API + Keycloak 192.168.1.9); HL_BACKEND-Weiche/usbReverse entfernt -> kein versehentliches localhost/Dev-Routing
- smoke_test_api: BackendConfig.prod statt entferntem .localDev

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:08:18 +02:00
f1e48cb177 chore(branding): Anzeigename 'Holzleitner Auslieferung' + Launcher-Icon
- Android android:label, iOS CFBundleDisplayName, MaterialApp.title -> 'Holzleitner Auslieferung'
- flutter_launcher_icons (assets/launch_icon.png) als Dev-Dep + Config

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:08:18 +02:00
50 changed files with 954 additions and 365 deletions

View File

@ -15,9 +15,9 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application <application
android:label="hl_lieferservice" android:label="Holzleitner Auslieferung"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/launcher_icon"
android:networkSecurityConfig="@xml/network_security_config"> android:networkSecurityConfig="@xml/network_security_config">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/launch_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -540,7 +540,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@ -597,7 +597,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";

View File

@ -1,122 +1 @@
{ {"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Hl Lieferservice</string> <string>Holzleitner Auslieferung</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>

View File

@ -1,16 +1,9 @@
/// Endpoint-Konfiguration für das Rust-Backend. /// Endpoint-Konfiguration für das Rust-Backend.
/// ///
/// Diese Übergangs-Konfiguration für die Backend-Migration wird in /// Produktiv-Setup: Backend und Keycloak laufen auf dem Host `192.168.1.9`.
/// Phase D durch eine umfassendere Konfigurations-Ablösung verfeinert /// Es gibt bewusst KEINE Dev-/localhost-/Tunnel-Profile und keine
/// (Build-Time-Flavor pro Stage etc.). /// `HL_BACKEND`-Umschaltung mehr — die App zeigt immer auf Prod. So kann kein
/// /// Build versehentlich auf `localhost` oder eine Dev-IP zeigen.
/// **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 { class BackendConfig {
const BackendConfig({ const BackendConfig({
required this.apiBaseUrl, required this.apiBaseUrl,
@ -24,11 +17,11 @@ class BackendConfig {
/// Realm-Issuer ohne `/.well-known/...`-Suffix — /// Realm-Issuer ohne `/.well-known/...`-Suffix —
/// `flutter_appauth` hängt das selbst an für die Discovery. /// `flutter_appauth` hängt das selbst an für die Discovery.
/// Beispiel: `http://localhost:8080/realms/holzleitner`. /// Beispiel: `http://192.168.1.9:8080/realms/holzleitner`.
/// ///
/// **Achtung:** Keycloak prägt das `iss`-Claim aus dem Hostnamen /// **Achtung:** Keycloak prägt das `iss`-Claim aus dem Hostnamen
/// dieser URL. Das Backend erwartet exakt diesen String als /// dieser URL. Das Backend erwartet exakt diesen String als
/// `KEYCLOAK_ISSUER_URL`. Mismatch → 401 mit `invalid issuer`. /// `issuer_url`. Mismatch → 401 mit `invalid issuer`.
final String keycloakIssuerUrl; final String keycloakIssuerUrl;
/// Token-Endpoint des Realms — abgeleitet aus dem Issuer. /// Token-Endpoint des Realms — abgeleitet aus dem Issuer.
@ -45,52 +38,16 @@ class BackendConfig {
/// matchen. /// matchen.
final String keycloakRedirectUrl; final String keycloakRedirectUrl;
/// Default-Konfiguration für lokale Entwicklung gegen das /// Produktiv-Konfiguration — einzige Quelle der Wahrheit.
/// Docker-Compose-Setup (Postgres + Keycloak + Backend). static const BackendConfig prod = BackendConfig(
static const BackendConfig localDev = BackendConfig( apiBaseUrl: 'http://192.168.1.9:3000',
apiBaseUrl: 'http://192.168.0.138:3000', keycloakIssuerUrl: 'http://192.168.1.9:8080/realms/holzleitner',
keycloakIssuerUrl: 'http://192.168.0.138:8080/realms/holzleitner',
keycloakClientId: 'holzleitner-app', keycloakClientId: 'holzleitner-app',
keycloakRedirectUrl: 'holzleitner://oauth2redirect', keycloakRedirectUrl: 'holzleitner://oauth2redirect',
); );
/// Konfiguration für USB-Tunnel via `adb reverse` — gedacht für Tests in /// Aktive Konfiguration. Früher per `--dart-define=HL_BACKEND` zwischen
/// fremden Netzwerken, in denen das Gerät den Mac nicht über eine LAN-IP /// Dev-Profilen (usb-Tunnel / LAN-IP) umschaltbar — entfernt. Das Flag wird
/// erreicht. Alles zeigt auf `localhost`; der Traffic wird über den /// jetzt ignoriert; es zählt immer [prod].
/// USB-Bus zum Host getunnelt. static const BackendConfig fromEnvironment = prod;
///
/// **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

@ -48,15 +48,32 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
late final StreamSubscription<AuthSessionEvent> _subscription; late final StreamSubscription<AuthSessionEvent> _subscription;
/// Timeout für den interaktiven Login. Schützt den Fahrer vor dem
/// „Spinner dreht ewig"-Fall, wenn der native `flutter_appauth`-Aufruf
/// (Browser-Tab + Token-Exchange) im Verbindungsabbruch / Issuer-Hang
/// stecken bleibt. Bewusst etwas knapper als der Restore-Timeout
/// (15 s im `_handleRestore`) — beim manuellen Login wartet der User
/// aktiv vor dem Bildschirm.
static const Duration _loginTimeout = Duration(seconds: 10);
Future<void> _handleLogin( Future<void> _handleLogin(
LoginRequested event, LoginRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
) async { ) async {
try { try {
emit(Authenticating()); emit(Authenticating());
await tokenProvider.login(); await tokenProvider.login().timeout(_loginTimeout);
// Erfolg landet via Stream-Subscription als // Erfolg landet via Stream-Subscription als
// ProviderSessionChanged.loggedIn → _handleProviderEvent. // ProviderSessionChanged.loggedIn → _handleProviderEvent.
} on TimeoutException {
// Native Login-Routine läuft im Hintergrund ggf. weiter. Sollte sie
// doch noch erfolgreich durchlaufen, feuert der Provider-Stream
// später ein `AuthLoggedIn` — der ProviderEvent-Handler hebt den
// State dann auf `Authenticated`. Kein Schaden, nur „nachträgliche
// Anmeldung". Bei späterer Exception passiert nichts; das Future
// ist hier nicht mehr awaited.
debugPrint('Login-Timeout nach ${_loginTimeout.inSeconds}s.');
emit(Unauthenticated(loginTimedOut: true));
} catch (err, st) { } catch (err, st) {
debugPrint('Login fehlgeschlagen: $err\n$st'); debugPrint('Login fehlgeschlagen: $err\n$st');
emit(Unauthenticated()); emit(Unauthenticated());

View File

@ -10,7 +10,14 @@ class AuthBootstrapping extends AuthState {}
class Unauthenticated extends AuthState { class Unauthenticated extends AuthState {
final bool sessionExpired; final bool sessionExpired;
Unauthenticated({this.sessionExpired = false});
/// `true`, wenn der letzte Login-Versuch in das 10-s-Timeout im
/// [AuthBloc] gelaufen ist (z. B. Verbindungsabbruch während
/// `tokenProvider.login()` oder hängender Issuer). Die [LoginPage]
/// blendet daraufhin einen Hinweis ein.
final bool loginTimedOut;
Unauthenticated({this.sessionExpired = false, this.loginTimedOut = false});
} }
/// Transient state während dem PKCE-Flow (Browser-Tab offen, /// Transient state während dem PKCE-Flow (Browser-Tab offen,

View File

@ -28,8 +28,11 @@ class LoginEnforcer extends StatelessWidget {
if (state is AuthBootstrapping) { if (state is AuthBootstrapping) {
return const _AuthBootstrapSplash(); return const _AuthBootstrapSplash();
} }
final expired = state is Unauthenticated && state.sessionExpired; final unauth = state is Unauthenticated ? state : null;
return LoginPage(sessionExpired: expired); return LoginPage(
sessionExpired: unauth?.sessionExpired ?? false,
loginTimedOut: unauth?.loginTimedOut ?? false,
);
}, },
); );
} }

View File

@ -12,10 +12,19 @@ import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
/// Browser-Tab, der RedirectURI `holzleitner://oauth2redirect` kommt /// Browser-Tab, der RedirectURI `holzleitner://oauth2redirect` kommt
/// zurück, der Code wird gegen Tokens getauscht. /// zurück, der Code wird gegen Tokens getauscht.
class LoginPage extends StatelessWidget { class LoginPage extends StatelessWidget {
const LoginPage({super.key, this.sessionExpired = false}); const LoginPage({
super.key,
this.sessionExpired = false,
this.loginTimedOut = false,
});
final bool sessionExpired; final bool sessionExpired;
/// Hinweis-Banner, dass der vorherige Login-Versuch ins 10-s-Timeout
/// gelaufen ist (typisch: Verbindungsabbruch oder hängender Issuer).
/// Wird vom `LoginEnforcer` aus `Unauthenticated.loginTimedOut` gefüttert.
final bool loginTimedOut;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -36,6 +45,20 @@ class LoginPage extends StatelessWidget {
), ),
actions: const [SizedBox.shrink()], actions: const [SizedBox.shrink()],
), ),
if (loginTimedOut)
MaterialBanner(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
content: const Text(
'Einloggen nicht möglich. Später erneut versuchen.',
style: TextStyle(color: Colors.white),
),
backgroundColor: Colors.red.shade700,
leading: const Icon(
Icons.cloud_off,
color: Colors.white,
),
actions: const [SizedBox.shrink()],
),
Expanded( Expanded(
child: Center( child: Center(
child: Column( child: Column(

View File

@ -73,15 +73,59 @@ class DeliveryDetail extends StatelessWidget {
} }
} }
class _DeliveryDetailScaffold extends StatelessWidget { class _DeliveryDetailScaffold extends StatefulWidget {
const _DeliveryDetailScaffold({required this.deliveryId}); const _DeliveryDetailScaffold({required this.deliveryId});
final String deliveryId; final String deliveryId;
@override
State<_DeliveryDetailScaffold> createState() =>
_DeliveryDetailScaffoldState();
}
class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> {
/// „Gearmt" = die Lieferung war während dieser Page-Session aktiv. Nur dann
/// poppen wir bei `completed` automatisch zurück zur Übersicht. Öffnet der
/// Fahrer eine bereits abgeschlossene Lieferung, bleibt `_armed == false`
/// und die Page bleibt offen (kein ungewolltes Zurückspringen).
bool _armed = false;
bool _popped = false;
@override
void initState() {
super.initState();
final s = context.read<TourBloc>().state;
if (s is TourLoaded && _findDelivery(s.details)?.state == DeliveryState.active) {
_armed = true;
}
}
Delivery? _findDelivery(TourDetails details) {
for (final d in details.deliveries) {
if (d.id == widget.deliveryId) return d;
}
return null;
}
/// Nach erfolgreichem Abschluss (aktiv → completed) zurück zur Übersicht.
void _onTourState(BuildContext context, TourState state) {
if (_popped || state is! TourLoaded) return;
final d = _findDelivery(state.details);
if (d == null) return;
if (d.state == DeliveryState.active) {
_armed = true;
} else if (_armed && d.state == DeliveryState.completed) {
_popped = true;
Navigator.of(context).pop();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return BlocBuilder<TourBloc, TourState>( return BlocListener<TourBloc, TourState>(
listener: _onTourState,
child: BlocBuilder<TourBloc, TourState>(
builder: (context, tourState) { builder: (context, tourState) {
if (tourState is! TourLoaded) { if (tourState is! TourLoaded) {
return const Scaffold( return const Scaffold(
@ -94,7 +138,9 @@ class _DeliveryDetailScaffold extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Lieferung')), appBar: AppBar(title: const Text('Lieferung')),
body: Center( body: Center(
child: Text('Lieferung $deliveryId nicht in der Tour gefunden.'), child: Text(
'Lieferung ${widget.deliveryId} nicht in der Tour gefunden.',
),
), ),
); );
} }
@ -118,15 +164,9 @@ class _DeliveryDetailScaffold extends StatelessWidget {
), ),
); );
}, },
),
); );
} }
Delivery? _findDelivery(TourDetails details) {
for (final d in details.deliveries) {
if (d.id == deliveryId) return d;
}
return null;
}
} }
// ─── Step-Header (Pills) ──────────────────────────────────────────────── // ─── Step-Header (Pills) ────────────────────────────────────────────────

View File

@ -49,6 +49,24 @@ class StepArticles extends StatelessWidget {
return (a.komponentenArtikelNr ?? '') return (a.komponentenArtikelNr ?? '')
.compareTo(b.komponentenArtikelNr ?? ''); .compareTo(b.komponentenArtikelNr ?? '');
}); });
// Komponenten je Oberartikel (Artikelnummer des Parents → Komponenten).
// Grundlage für E: Komponenten sind einzeln NICHT entfernbar — nur der
// Oberartikel kann (ganzes Set) entfernt werden, was auf die Komponenten
// kaskadiert.
final componentsByParentNr = <String, List<DeliveryItem>>{};
for (final it in items) {
final pNr = it.parentArtikelNr;
if (it.isComponent && pNr != null) {
componentsByParentNr.putIfAbsent(pNr, () => []).add(it);
}
}
List<DeliveryItem> componentsOf(DeliveryItem it) {
final nr = details.articleOf(it.articleId)?.articleNumber;
if (nr == null) return const [];
return componentsByParentNr[nr] ?? const [];
}
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [ children: [
@ -73,6 +91,7 @@ class StepArticles extends StatelessWidget {
details: details, details: details,
deliveryId: delivery.id, deliveryId: delivery.id,
deliveryActive: delivery.state == DeliveryState.active, deliveryActive: delivery.state == DeliveryState.active,
components: componentsOf(items[i]),
), ),
if (i < items.length - 1) if (i < items.length - 1)
const Divider(height: 1, indent: 16, endIndent: 16), const Divider(height: 1, indent: 16, endIndent: 16),
@ -149,6 +168,7 @@ class _ArticleManagementRow extends StatelessWidget {
required this.details, required this.details,
required this.deliveryId, required this.deliveryId,
required this.deliveryActive, required this.deliveryActive,
this.components = const [],
}); });
final DeliveryItem item; final DeliveryItem item;
@ -156,6 +176,10 @@ class _ArticleManagementRow extends StatelessWidget {
final String deliveryId; final String deliveryId;
final bool deliveryActive; final bool deliveryActive;
/// Komponenten dieses Items, falls es ein Set-Oberartikel ist (sonst leer).
/// Entfernen/Wiederherstellen kaskadiert auf diese Komponenten.
final List<DeliveryItem> components;
Future<void> _openCreditDialog( Future<void> _openCreditDialog(
BuildContext context, { BuildContext context, {
required int remaining, required int remaining,
@ -193,6 +217,68 @@ class _ArticleManagementRow extends StatelessWidget {
)); ));
} }
/// Entfernt eine Anzahl Sets über den Oberartikel und kaskadiert auf seine
/// Komponenten. Der Mengen-Stepper bleibt erhalten — ein Set kann mehrfach
/// bestellt sein (Oberartikel-Menge = Set-Anzahl). Die Komponenten werden
/// **proportional** mitreduziert (Stückzahl je Set × entfernte Sets),
/// geklemmt auf die jeweilige Restmenge. Einzelne Komponenten bleiben
/// nicht direkt entfernbar — nur dieser Weg über den Oberartikel.
Future<void> _openSetRemoveDialog(
BuildContext context, {
required int remaining,
}) async {
final tourBloc = context.read<TourBloc>();
final actorCarId = _actorCarId(context);
final result = await showReasonPickerSheet(
context: context,
title: 'Grund für das Entfernen',
presets: ReasonCatalog.itemRemove,
confirmLabel: 'Entfernen',
maxQuantity: remaining,
);
if (result == null) return;
final n = result.quantity ?? remaining;
final parentRequired = item.requiredQuantity;
// Oberartikel um n Sets reduzieren.
tourBloc.add(RemoveItem(
deliveryItemId: item.id,
reason: result.reason,
actorCarId: actorCarId,
quantity: n,
saveReasonAsNote: true,
));
// Komponenten proportional mitreduzieren.
for (final c in components) {
final cRemaining = c.requiredQuantity - c.scanProgress.creditedQuantity;
if (cRemaining <= 0) continue;
final proportional = parentRequired > 0
? (c.requiredQuantity * n / parentRequired).round()
: cRemaining;
final removeQty = proportional.clamp(0, cRemaining);
if (removeQty <= 0) continue;
tourBloc.add(RemoveItem(
deliveryItemId: c.id,
reason: result.reason,
actorCarId: actorCarId,
quantity: removeQty,
// Grund nur einmal (am Oberartikel) als Notiz festhalten.
saveReasonAsNote: false,
));
}
}
/// Stellt das ganze Set wieder her (Oberartikel + Komponenten).
void _restoreSet(BuildContext context) {
final tourBloc = context.read<TourBloc>();
final actorCarId = _actorCarId(context);
tourBloc.add(UnremoveItem(deliveryItemId: item.id, actorCarId: actorCarId));
for (final c in components) {
tourBloc
.add(UnremoveItem(deliveryItemId: c.id, actorCarId: actorCarId));
}
}
/// Holt die aktuelle Auto-ID aus dem CarSelectBloc — gleiche Konvention /// Holt die aktuelle Auto-ID aus dem CarSelectBloc — gleiche Konvention
/// wie der Loading-Flow: das aktiv gewählte Fahrzeug ist der „Akteur" /// wie der Loading-Flow: das aktiv gewählte Fahrzeug ist der „Akteur"
/// des Audit-Events. Falls (defensiv) noch keine Auswahl getroffen ist, /// des Audit-Events. Falls (defensiv) noch keine Auswahl getroffen ist,
@ -216,11 +302,27 @@ class _ArticleManagementRow extends StatelessWidget {
final fullyRemoved = item.isRemoved; // Status == removed (voll gutgeschr.) final fullyRemoved = item.isRemoved; // Status == removed (voll gutgeschr.)
final partiallyCredited = credited > 0 && !fullyRemoved; final partiallyCredited = credited > 0 && !fullyRemoved;
// E: Komponenten eines Sets sind einzeln NICHT entfernbar; nur der
// Oberartikel kann (ganzes Set) entfernt werden.
final isComponent = item.isComponent;
final hasComponents = components.isNotEmpty;
// Gate: scannbare Position muss `done` sein, sonst keine Gutschrift. // Gate: scannbare Position muss `done` sein, sonst keine Gutschrift.
final scannable = article?.scannable ?? false; final scannable = article?.scannable ?? false;
final isDone = item.scanProgress.status == ScanStatus.done; final isDone = item.scanProgress.status == ScanStatus.done;
final blockedByScan = scannable && !isDone && !fullyRemoved; final blockedByScan = scannable && !isDone && !fullyRemoved;
final canCredit = deliveryActive && !blockedByScan && remaining > 0; // Beim Set zusätzlich: jede scanbare Komponente muss erst verladen sein,
// sonst würde das Backend deren Entfernen ablehnen.
final componentsBlocked = components.any((c) {
final cArticle = details.articleOf(c.articleId);
final cScannable = cArticle?.scannable ?? false;
final cDone = c.scanProgress.status == ScanStatus.done;
return cScannable && !cDone && !c.isRemoved;
});
final canCredit = deliveryActive &&
!blockedByScan &&
!componentsBlocked &&
remaining > 0;
final Color avatarColor; final Color avatarColor;
final String avatarText; final String avatarText;
@ -285,18 +387,40 @@ class _ArticleManagementRow extends StatelessWidget {
text: 'Erst scannen/verladen — dann Gutschrift möglich', text: 'Erst scannen/verladen — dann Gutschrift möglich',
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
// E: Komponenten-Hinweis — einzeln nicht entfernbar.
if (isComponent && deliveryActive && !fullyRemoved)
_StatusLine(
text: 'Nur über den Oberartikel entfernbar',
color: theme.colorScheme.onSurfaceVariant,
),
// Set-Oberartikel: blockiert, solange eine Komponente noch nicht
// verladen ist.
if (hasComponents &&
componentsBlocked &&
deliveryActive &&
!fullyRemoved)
_StatusLine(
text: 'Erst alle Set-Teile verladen — dann ganzes Set entfernbar',
color: theme.colorScheme.onSurfaceVariant,
),
], ],
), ),
trailing: Row( // E: Komponenten haben KEINE eigenen Aktionen — Entfernen/Wiederherstellen
// läuft ausschließlich über den Oberartikel (kaskadiert auf das Set).
trailing: isComponent
? null
: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (credited > 0) if (credited > 0)
IconButton( IconButton(
// Wiederherstellen nur bei aktiver Lieferung — bei // Wiederherstellen nur bei aktiver Lieferung — bei
// abgeschlossener/abgebrochener Lieferung gesperrt (greift auch // abgeschlossener/abgebrochener Lieferung gesperrt (greift
// backend-seitig, hier zusätzlich in der UI). // auch backend-seitig, hier zusätzlich in der UI).
tooltip: deliveryActive tooltip: deliveryActive
? 'Gutschrift zurücknehmen' ? (hasComponents
? 'Set wiederherstellen'
: 'Gutschrift zurücknehmen')
: 'Nur bei aktiver Lieferung', : 'Nur bei aktiver Lieferung',
icon: Icon( icon: Icon(
Icons.restore, Icons.restore,
@ -304,15 +428,21 @@ class _ArticleManagementRow extends StatelessWidget {
? theme.colorScheme.primary ? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant, : theme.colorScheme.onSurfaceVariant,
), ),
onPressed: deliveryActive ? () => _restoreAll(context) : null, onPressed: deliveryActive
? () => hasComponents
? _restoreSet(context)
: _restoreAll(context)
: null,
), ),
if (!fullyRemoved) if (!fullyRemoved)
IconButton.outlined( IconButton.outlined(
tooltip: blockedByScan tooltip: (blockedByScan || componentsBlocked)
? 'Erst scannen/verladen' ? 'Erst scannen/verladen'
: (!deliveryActive : (!deliveryActive
? 'Nur bei aktiver Lieferung' ? 'Nur bei aktiver Lieferung'
: 'Gutschrift / entfernen'), : (hasComponents
? 'Ganzes Set entfernen'
: 'Gutschrift / entfernen')),
style: ButtonStyle( style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll( backgroundColor: WidgetStatePropertyAll(
canCredit canCredit
@ -321,7 +451,9 @@ class _ArticleManagementRow extends StatelessWidget {
), ),
), ),
onPressed: canCredit onPressed: canCredit
? () => _openCreditDialog(context, remaining: remaining) ? () => hasComponents
? _openSetRemoveDialog(context, remaining: remaining)
: _openCreditDialog(context, remaining: remaining)
: null, : null,
icon: Icon( icon: Icon(
Icons.delete, Icons.delete,

View File

@ -9,6 +9,9 @@ import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart'; import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart';
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart';
/// Step 1 — Informationen zur Lieferung. /// Step 1 — Informationen zur Lieferung.
/// ///
@ -716,7 +719,19 @@ class _ArticleList extends StatelessWidget {
); );
} }
return Card( // Hat sich bei mindestens einem Artikel die tatsächliche Menge geändert
// (entfernt oder teilweise gutgeschrieben)? Dann ein Banner mit Verweis
// auf Step 3 „Artikel", wo die Änderungen verwaltet werden.
final anyChanged = items.any(_quantityChanged);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (anyChanged) ...[
const _QuantityChangedBanner(),
const SizedBox(height: 8),
],
Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: Column( child: Column(
children: [ children: [
@ -727,6 +742,55 @@ class _ArticleList extends StatelessWidget {
], ],
], ],
), ),
),
],
);
}
}
/// `true`, wenn die tatsächlich auszuliefernde Menge von der ursprünglich
/// bestellten abweicht — also die Position ganz entfernt oder teilweise
/// gutgeschrieben wurde.
bool _quantityChanged(DeliveryItem item) =>
item.isRemoved || item.scanProgress.creditedQuantity > 0;
/// Tappbares Banner über der Artikelliste: weist darauf hin, dass sich die
/// Menge mindestens eines Artikels geändert hat, und springt zu Step 3
/// („Artikel"), wo die Änderungen sichtbar/verwaltbar sind.
class _QuantityChangedBanner extends StatelessWidget {
const _QuantityChangedBanner();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final amber = Colors.amber.shade800;
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => context
.read<DeliveryWorkflowBloc>()
.add(const WorkflowGoToStep(WorkflowStep.articles)),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: amber.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: amber.withValues(alpha: 0.4)),
),
child: Row(
children: [
Icon(Icons.edit_note, color: amber),
const SizedBox(width: 10),
Expanded(
child: Text(
'Bei mindestens einem Artikel hat sich die Menge geändert. '
'Details unter Schritt 3 „Artikel".',
style: theme.textTheme.bodySmall?.copyWith(color: amber),
),
),
Icon(Icons.chevron_right, color: amber, size: 20),
],
),
),
); );
} }
} }
@ -744,6 +808,8 @@ class _ArticleRow extends StatelessWidget {
final warehouse = details.warehouseOf(item.warehouseId); final warehouse = details.warehouseOf(item.warehouseId);
final isScannable = article?.scannable ?? false; final isScannable = article?.scannable ?? false;
final removed = item.isRemoved; final removed = item.isRemoved;
// Menge tatsächlich verändert (entfernt oder teilweise gutgeschrieben)?
final changed = _quantityChanged(item);
return ListTile( return ListTile(
// Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber). // Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber).
@ -756,6 +822,8 @@ class _ArticleRow extends StatelessWidget {
foregroundColor: removed foregroundColor: removed
? theme.colorScheme.onSurfaceVariant ? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onPrimary, : theme.colorScheme.onPrimary,
// Bewusst die URSPRÜNGLICH bestellte Menge (requiredQuantity), nicht
// die reduzierte — Änderungen werden separat als Hinweis ausgewiesen.
child: Text( child: Text(
'${item.requiredQuantity}×', '${item.requiredQuantity}×',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
@ -769,7 +837,10 @@ class _ArticleRow extends StatelessWidget {
color: removed ? theme.colorScheme.onSurfaceVariant : null, color: removed ? theme.colorScheme.onSurfaceVariant : null,
), ),
), ),
subtitle: Text( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
[ [
article?.articleNumber ?? item.articleId, article?.articleNumber ?? item.articleId,
if (warehouse != null) warehouse.name, if (warehouse != null) warehouse.name,
@ -783,6 +854,31 @@ class _ArticleRow extends StatelessWidget {
decoration: removed ? TextDecoration.lineThrough : null, decoration: removed ? TextDecoration.lineThrough : null,
), ),
), ),
if (changed)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Row(
children: [
Icon(Icons.edit_note, size: 14, color: Colors.amber.shade800),
const SizedBox(width: 4),
Flexible(
child: Text(
removed
? 'Menge geändert: entfernt (Ursprung ${item.requiredQuantity}×)'
: 'Menge geändert: jetzt ${item.deliveredQuantity}× '
'(Ursprung ${item.requiredQuantity}×)',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.amber.shade800,
),
),
),
],
),
),
],
),
trailing: isScannable trailing: isScannable
? Text( ? Text(
'${item.scanProgress.scannedQuantity} / ${item.requiredQuantity}', '${item.scanProgress.scannedQuantity} / ${item.requiredQuantity}',

View File

@ -56,6 +56,7 @@ class StepSummary extends StatelessWidget {
_PaymentMethodPicker( _PaymentMethodPicker(
delivery: delivery, delivery: delivery,
overrideId: wfState.paymentMethodOverrideId, overrideId: wfState.paymentMethodOverrideId,
credit: details.creditOf(delivery.id),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const _SignHint(), const _SignHint(),
@ -213,6 +214,17 @@ class _DeliveredRow extends StatelessWidget {
} }
} }
/// Offener Betrag der Lieferung in Euro: Warenwert (Σ Stückpreis × gelieferte
/// Menge) Anzahlung Gutschrift, nie negativ. Einzige Quelle dieser Formel —
/// genutzt von der Zahlungs-Übersicht UND der Zahlungsmethoden-Auswahl.
double _openAmount(Delivery delivery, DeliveryCredit? credit) {
final creditEuros = (credit?.amountCents ?? 0) / 100.0;
final warenwert =
delivery.items.fold<double>(0, (acc, item) => acc + item.lineTotal);
return (warenwert - delivery.prepaidAmount - creditEuros)
.clamp(0.0, double.infinity);
}
class _PaymentSummary extends StatelessWidget { class _PaymentSummary extends StatelessWidget {
const _PaymentSummary({required this.delivery, required this.credit}); const _PaymentSummary({required this.delivery, required this.credit});
@ -222,15 +234,13 @@ class _PaymentSummary extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
// Exakt aus Cent (nicht gerundet) — Gutschrift kann Cent-Beträge haben.
final creditEuros = (credit?.amountCents ?? 0) / 100.0;
// Warenwert = Σ Stückpreis × ausgelieferte Menge (entfernte/teil-entfernte // Warenwert = Σ Stückpreis × ausgelieferte Menge (entfernte/teil-entfernte
// Positionen fallen automatisch raus). // Positionen fallen automatisch raus).
final warenwert = delivery.items final warenwert = delivery.items
.fold<double>(0, (acc, item) => acc + item.lineTotal); .fold<double>(0, (acc, item) => acc + item.lineTotal);
// Offener Betrag = Warenwert Anzahlung Gutschrift, nie negativ. // Offener Betrag über den gemeinsamen Helper (gleiche Formel wie die
final open = (warenwert - delivery.prepaidAmount - creditEuros) // Zahlungsmethoden-Auswahl).
.clamp(0.0, double.infinity); final open = _openAmount(delivery, credit);
return Card( return Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: Padding( child: Padding(
@ -349,10 +359,12 @@ class _PaymentMethodPicker extends StatelessWidget {
const _PaymentMethodPicker({ const _PaymentMethodPicker({
required this.delivery, required this.delivery,
required this.overrideId, required this.overrideId,
required this.credit,
}); });
final Delivery delivery; final Delivery delivery;
final String? overrideId; final String? overrideId;
final DeliveryCredit? credit;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -407,6 +419,11 @@ class _PaymentMethodPicker extends StatelessWidget {
// abgeschlossener/abgebrochener/pausierter Lieferung zeigt das // abgeschlossener/abgebrochener/pausierter Lieferung zeigt das
// Dropdown den gewählten Stand, ist aber gesperrt. // Dropdown den gewählten Stand, ist aber gesperrt.
final active = delivery.state == DeliveryState.active; final active = delivery.state == DeliveryState.active;
// Steht kein offener Betrag mehr aus (vollständig vorab bezahlt
// oder per Gutschrift ausgeglichen), ist keine Zahlungsweise zu
// wählen → Auswahl deaktivieren.
final hasOpenAmount = _openAmount(delivery, credit) > 0;
final editable = active && hasOpenAmount;
return Card( return Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: Padding( child: Padding(
@ -428,7 +445,7 @@ class _PaymentMethodPicker extends StatelessWidget {
), ),
], ],
// `null` deaktiviert das Dropdown (Flutter-Konvention). // `null` deaktiviert das Dropdown (Flutter-Konvention).
onChanged: active onChanged: editable
? (newId) { ? (newId) {
if (newId == null) return; if (newId == null) return;
context.read<DeliveryWorkflowBloc>().add( context.read<DeliveryWorkflowBloc>().add(
@ -447,27 +464,15 @@ class _PaymentMethodPicker extends StatelessWidget {
), ),
if (!active) ...[ if (!active) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Row( const _PickerHint(
children: [ text: 'Lieferung abgeschlossen — Zahlungsmethode nicht '
Icon(Icons.lock_outline,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
'Lieferung abgeschlossen — Zahlungsmethode nicht '
'mehr änderbar.', 'mehr änderbar.',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
), ),
), ] else if (!hasOpenAmount) ...[
), const SizedBox(height: 8),
], const _PickerHint(
text: 'Keine Zahlung mehr offen (bereits bezahlt) — '
'Auswahl der Zahlungsweise nicht erforderlich.',
), ),
], ],
], ],
@ -479,6 +484,31 @@ class _PaymentMethodPicker extends StatelessWidget {
} }
} }
/// Dezenter Sperr-/Info-Hinweis unter dem Zahlungsmethoden-Dropdown
/// (Schloss-Icon + Text in gedämpfter Farbe).
class _PickerHint extends StatelessWidget {
const _PickerHint({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return Row(
children: [
Icon(Icons.lock_outline, size: 16, color: muted),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: muted),
),
),
],
);
}
}
class _SignHint extends StatelessWidget { class _SignHint extends StatelessWidget {
const _SignHint(); const _SignHint();

View File

@ -374,6 +374,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
} }
if (state is TourEmpty) { if (state is TourEmpty) {
return Scaffold( return Scaffold(
drawer: const HomeAppDrawer(),
appBar: AppBar(title: const Text('Lieferungen auswählen')), appBar: AppBar(title: const Text('Lieferungen auswählen')),
body: const Center( body: const Center(
child: Padding( child: Padding(
@ -388,8 +389,11 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
); );
} }
if (state is! TourLoaded) { if (state is! TourLoaded) {
return const Scaffold( // Drawer auch hier — Fahrer soll im Lade-Hang ausloggen können.
body: Center(child: CircularProgressIndicator()), return Scaffold(
drawer: const HomeAppDrawer(),
appBar: AppBar(title: const Text('Lieferungen auswählen')),
body: const Center(child: CircularProgressIndicator()),
); );
} }

View File

@ -157,6 +157,15 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
const SizedBox(height: 20),
// Erneut die heutige Tour vom Backend laden. `LoadTour` zeigt
// währenddessen den Lade-Branch (Spinner) und landet danach wieder
// hier (TourEmpty) oder in der sortierbaren Lieferungsliste.
FilledButton.tonalIcon(
onPressed: () => context.read<TourBloc>().add(const LoadTour()),
icon: const Icon(Icons.refresh),
label: const Text('Neu laden'),
),
], ],
); );
} }
@ -223,15 +232,39 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
} }
}, },
builder: (context, state) { builder: (context, state) {
// Drawer in jedem Branch beibehalten — sonst sitzt der Fahrer im
// „Keine Tour heute"- oder Lade-Screen fest, ohne Zugriff auf
// Einstellungen / Logout.
if (state is TourEmpty) { if (state is TourEmpty) {
// Auch im Leerzustand die Steps-AppBar (PhaseStepper, primary)
// zeigen — nur die mittige Nachricht bleibt. So bleibt die Phasen-
// Navigation sichtbar und die Optik konsistent zum geladenen Zustand.
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Sortieren')), drawer: const HomeAppDrawer(),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(140),
child: PhaseStepper(
currentPhase: DeliveryPhase.sortieren,
carId: widget.selectedCarId,
),
),
body: _emptyState(), body: _emptyState(),
); );
} }
if (state is! TourLoaded) { if (state is! TourLoaded) {
return const Scaffold( // Lade-Zwischenzustand (auch beim 'Neu laden') mit derselben
body: Center(child: CircularProgressIndicator()), // PhaseStepper-AppBar — sonst flackert die Leiste kurz auf die
// schlichte AppBar zurück.
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(140),
child: PhaseStepper(
currentPhase: DeliveryPhase.sortieren,
carId: widget.selectedCarId,
),
),
body: const Center(child: CircularProgressIndicator()),
); );
} }

View File

@ -11,9 +11,12 @@ import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
import 'package:hl_lieferservice/feature/loading/widget/reason_catalog.dart'; import 'package:hl_lieferservice/feature/loading/widget/reason_catalog.dart';
import 'package:hl_lieferservice/feature/loading/widget/reason_picker_sheet.dart'; import 'package:hl_lieferservice/feature/loading/widget/reason_picker_sheet.dart';
import 'package:hl_lieferservice/widget/scanner/article_scanner_stripe.dart'; import 'package:hl_lieferservice/widget/scanner/article_scanner_stripe.dart';
@ -57,6 +60,10 @@ class _LoadingCustomerPageState extends State<LoadingCustomerPage> {
/// vom (lebenslang einen) Scanner für die Barcode-Auflösung benutzt. /// vom (lebenslang einen) Scanner für die Barcode-Auflösung benutzt.
int _currentIndex = 0; int _currentIndex = 0;
/// Verhindert, dass der „Alles gescannt"-Abschluss-Dialog mehrfach erscheint.
/// Wird zurückgesetzt, sobald wieder etwas zu beladen offen ist.
bool _completionPromptShown = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -331,13 +338,78 @@ class _LoadingCustomerPageState extends State<LoadingCustomerPage> {
} }
} }
/// Reagiert auf jedes TourBloc-Update: Sind ALLE eigenen, **aktiven**
/// Lieferungen im Standardlager fertig beladen (dieselbe Bedingung wie der
/// „Auslieferungs-Phase starten"-Gate der Beladen-Übersicht)? Genau beim
/// Übergang „noch offen → alles fertig" (also nach dem letzten Pflicht-Scan)
/// erscheint einmalig der Abschluss-Dialog.
void _maybePromptLoadingComplete(
BuildContext context,
TourState state,
String carId,
) {
if (state is! TourLoaded) return;
final deliveries = _ownInLoadingOrder(context, state.details, carId);
final active =
deliveries.where((d) => d.state == DeliveryState.active).toList();
final allDone = active.isNotEmpty &&
active.every(state.details.standardWarehouseLoadingDone);
if (!allDone) {
// Wieder etwas offen (Item entfernt/zurückgesetzt) → Dialog darf erneut.
_completionPromptShown = false;
return;
}
if (_completionPromptShown) return;
_completionPromptShown = true;
_showLoadingCompleteDialog(carId);
}
/// Abschluss-Dialog der Beladung: weist darauf hin, dass alles gescannt ist,
/// und fragt, ob die Auslieferung gestartet werden soll. Bei „Ja" wird die
/// Phase auf `ausliefern` gesetzt (PhaseBloc, app-weit) und der Scanner
/// geschlossen — der Phasen-Router (`home`) zeigt dann die Auslieferungs-
/// Übersicht. „Später" schließt nur den Dialog (Start wie bisher über die
/// Übersicht möglich).
Future<void> _showLoadingCompleteDialog(String carId) async {
final start = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
icon: const Icon(Icons.check_circle, color: Colors.green, size: 40),
title: const Text('Alles gescannt'),
content: const Text(
'Alle zu beladenden Artikel sind gescannt. '
'Möchten Sie die Auslieferung starten?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Später'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Auslieferung starten'),
),
],
),
);
if (start != true || !mounted) return;
context
.read<PhaseBloc>()
.add(PhaseSet(carId: carId, phase: DeliveryPhase.ausliefern));
// Scanner schließen → zurück zum Phasen-Router, der nun 'ausliefern' zeigt.
Navigator.of(context).pop();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<CarSelectBloc, CarSelectState>( return BlocBuilder<CarSelectBloc, CarSelectState>(
builder: (context, carState) { builder: (context, carState) {
final carId = final carId =
carState is CarSelectComplete ? carState.selectedCar.id : ''; carState is CarSelectComplete ? carState.selectedCar.id : '';
return BlocBuilder<TourBloc, TourState>( return BlocConsumer<TourBloc, TourState>(
listener: (context, tourState) =>
_maybePromptLoadingComplete(context, tourState, carId),
builder: (context, tourState) { builder: (context, tourState) {
if (tourState is! TourLoaded) { if (tourState is! TourLoaded) {
return const Scaffold( return const Scaffold(
@ -463,6 +535,65 @@ class _CustomerBody extends StatelessWidget {
// erkennt. Liegen außerhalb von `groups` (die nur scanbare Items führen). // erkennt. Liegen außerhalb von `groups` (die nur scanbare Items führen).
final serviceItems = details.nonScannableItems(delivery).toList(); final serviceItems = details.nonScannableItems(delivery).toList();
// ── Set-Köpfe (Parent-Komponenten) immer mit ihrem Set anzeigen ──────
// Ein nicht-scanbarer Set-Kopf (Stücklisten-Oberartikel, z. B. als
// Pauschale geführt) soll NICHT isoliert unter „Dienstleistungen"
// erscheinen, sondern als Kopf über seinen (scanbaren) Komponenten in der
// jeweiligen Lagergruppe. Ein Item ist Set-Kopf, wenn seine Artikelnummer
// von einer Komponente als parentArtikelNr referenziert wird.
String? artNrOf(DeliveryItem it) =>
details.articleOf(it.articleId)?.articleNumber;
// Alle Set-Köpfe (Parent-Artikel): ein Item, dessen Artikelnummer von einer
// Komponente als parentArtikelNr referenziert wird. Für nicht-scanbare
// Set-Köpfe wird der „kein Scanvorgang notwendig"-Hinweis unterdrückt.
final componentParentNrs = delivery.items
.where((it) => it.isComponent)
.map((it) => it.parentArtikelNr)
.whereType<String>()
.toSet();
final setParentIds = delivery.items
.where((it) {
final nr = artNrOf(it);
return nr != null && componentParentNrs.contains(nr);
})
.map((it) => it.id)
.toSet();
// Set-Kopf „komplett": alle scanbaren, nicht entfernten Komponenten fertig.
// Dann wird auch der (selbst nicht scanbare) Kopf grün dargestellt.
bool setParentComplete(DeliveryItem parent) {
final nr = artNrOf(parent);
if (nr == null) return false;
final comps = delivery.items.where((c) =>
c.parentArtikelNr == nr &&
!c.isRemoved &&
(details.articleOf(c.articleId)?.scannable ?? false));
return comps.isNotEmpty && comps.every((c) => c.isDone);
}
// Set-Köpfe je Lagergruppe (warehouseId → einzuhängende Köpfe) +
// gesammelte IDs, um sie aus der Dienstleistungs-Sektion zu entfernen.
final injectedParentsByWarehouseId = <String, List<DeliveryItem>>{};
final injectedParentIds = <String>{};
for (final group in groups) {
final groupParentNrs = group.items
.where((it) => it.isComponent)
.map((it) => it.parentArtikelNr)
.whereType<String>()
.toSet();
if (groupParentNrs.isEmpty) continue;
final parents = serviceItems
.where((s) => groupParentNrs.contains(artNrOf(s)))
.toList();
if (parents.isEmpty) continue;
injectedParentsByWarehouseId[group.warehouse.id] = parents;
injectedParentIds.addAll(parents.map((p) => p.id));
}
// Set-Köpfe, die in eine Gruppe eingehängt wurden, nicht doppelt unter
// „Dienstleistungen" zeigen. Set-Köpfe ohne scanbare Komponenten (kommen
// hier nicht vor) blieben weiterhin in der Liste.
final serviceItemsView = serviceItems
.where((s) => !injectedParentIds.contains(s.id))
.toList();
return Column( return Column(
children: [ children: [
// Header bekommt einen eigenen, leicht abgehobenen Hintergrund // Header bekommt einen eigenen, leicht abgehobenen Hintergrund
@ -555,26 +686,36 @@ class _CustomerBody extends StatelessWidget {
warehouse: group.warehouse, warehouse: group.warehouse,
items: group.items, items: group.items,
), ),
for (final item in _parentFirst(group.items)) // Nicht-scanbare Set-Köpfe dieser Gruppe vor ihre Komponenten
// einhängen; `_parentFirst` ordnet Kopf vor Komponenten.
for (final item in _parentFirst([
...?injectedParentsByWarehouseId[group.warehouse.id],
...group.items,
]))
_ItemRow( _ItemRow(
item: item, item: item,
details: details, details: details,
onAction: (action) => onItemAction(item, action), onAction: (action) => onItemAction(item, action),
suppressScanHint: setParentIds.contains(item.id),
setParentComplete: setParentIds.contains(item.id) &&
setParentComplete(item),
), ),
], ],
// Gebuchte Dienstleistungen (nicht-scanbare Positionen): eigene // Gebuchte Dienstleistungen (nicht-scanbare Positionen ohne
// Zwischenüberschrift „Dienstleistungen" (optisch wie // eigene Set-Komponenten): eigene Zwischenüberschrift
// Standardlager) und dieselbe Item-Card wie scanbare Artikel — // „Dienstleistungen" (optisch wie Standardlager) und dieselbe
// einziger Unterschied ist der Hinweis, dass kein Scanvorgang // Item-Card wie scanbare Artikel — einziger Unterschied ist der
// nötig ist (`scanNotRequired`). // Hinweis, dass kein Scanvorgang nötig ist.
if (serviceItems.isNotEmpty) ...[ if (serviceItemsView.isNotEmpty) ...[
const _ServiceSectionHeader(), const _ServiceSectionHeader(),
for (final item in _parentFirst(serviceItems)) for (final item in _parentFirst(serviceItemsView))
_ItemRow( _ItemRow(
item: item, item: item,
details: details, details: details,
onAction: (action) => onItemAction(item, action), onAction: (action) => onItemAction(item, action),
scanNotRequired: true, suppressScanHint: setParentIds.contains(item.id),
setParentComplete: setParentIds.contains(item.id) &&
setParentComplete(item),
), ),
], ],
], ],
@ -681,6 +822,44 @@ class _ScanNotRequiredHint extends StatelessWidget {
} }
} }
/// Grünes „Komplett geladen" für einen Set-Kopf (Parent-Artikel), dessen
/// (scanbare) Komponenten alle geladen sind. Ersetzt an dieser Stelle den
/// „Kein Scanvorgang notwendig"-Hinweis.
class _SetCompleteHint extends StatelessWidget {
const _SetCompleteHint();
@override
Widget build(BuildContext context) {
final color = Colors.green.shade700;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle, size: 16, color: color),
const SizedBox(width: 6),
Flexible(
child: Text(
'Komplett geladen',
style: TextStyle(
fontSize: 13,
color: color,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
/// Sektions-Kopf vor den Items eines Lagers. Visuell klar getrennt /// Sektions-Kopf vor den Items eines Lagers. Visuell klar getrennt
/// (Standardlager vs. Filiale): Standardlager ist der primäre /// (Standardlager vs. Filiale): Standardlager ist der primäre
/// Arbeitsplatz und damit neutral koloriert; Filial-Sections /// Arbeitsplatz und damit neutral koloriert; Filial-Sections
@ -1039,24 +1218,37 @@ class _ItemRow extends StatelessWidget {
required this.item, required this.item,
required this.details, required this.details,
required this.onAction, required this.onAction,
this.scanNotRequired = false, this.suppressScanHint = false,
this.setParentComplete = false,
}); });
final DeliveryItem item; final DeliveryItem item;
final TourDetails details; final TourDetails details;
final void Function(_ItemAction action) onAction; final void Function(_ItemAction action) onAction;
/// `true` für gebuchte Dienstleistungen (nicht-scanbare Positionen): die /// Unterdrückt den „Kein Scanvorgang notwendig"-Hinweis. Für nicht-scanbare
/// Card wird GENAU SO wie ein Standardlager-Artikel gerendert, bekommt aber /// **Set-Köpfe** (Parent-Artikel): dort ist der Hinweis irreführend, weil die
/// unten den Hinweis „Kein Scanvorgang notwendig". Der Manuell-Button /// (scanbaren) Komponenten darunter sehr wohl gescannt werden.
/// entfällt bei nicht-scanbaren Positionen ohnehin (`canManualConfirm`). final bool suppressScanHint;
final bool scanNotRequired;
/// `true`, wenn dies ein Set-Kopf ist, dessen Komponenten alle fertig
/// geladen sind. Dann wird der (selbst nicht scanbare) Kopf grün dargestellt.
final bool setParentComplete;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final article = details.articleOf(item.articleId); final article = details.articleOf(item.articleId);
final warehouse = details.warehouseOf(item.warehouseId); final warehouse = details.warehouseOf(item.warehouseId);
// `true` für nicht-scanbare Positionen (Dienstleistungen / Pauschalen /
// Set-Köpfe): die Card wird GENAU SO wie ein scanbarer Artikel gerendert,
// bekommt aber unten den Hinweis „Kein Scanvorgang notwendig" und keinen
// Mengen-Zähler. Aus dem Artikel abgeleitet, damit ein in eine Lagergruppe
// eingehängter nicht-scanbarer Set-Kopf automatisch korrekt rendert.
final scanNotRequired = !(article?.scannable ?? false);
// Effektiv „fertig": eigener Scan-Status ODER (Set-Kopf, dessen Komponenten
// alle geladen sind) → der Kopf wird dann ebenfalls grün.
final effectiveDone = item.isDone || setParentComplete;
final isExternalWarehouse = warehouse != null && !warehouse.isStandard; final isExternalWarehouse = warehouse != null && !warehouse.isStandard;
// Manueller Fallback-Button: nur für scanbare, noch offene Positionen // Manueller Fallback-Button: nur für scanbare, noch offene Positionen
// (nicht done/entfernt/pausiert) — analog dazu, was ein Barcode-Scan // (nicht done/entfernt/pausiert) — analog dazu, was ein Barcode-Scan
@ -1093,7 +1285,7 @@ class _ItemRow extends StatelessWidget {
leadingIcon = Icons.pause_circle_outline; leadingIcon = Icons.pause_circle_outline;
leadingIconColor = Colors.orange.shade800; leadingIconColor = Colors.orange.shade800;
statusBadgeLabel = 'Pausiert'; statusBadgeLabel = 'Pausiert';
} else if (item.isDone) { } else if (effectiveDone) {
cardColor = Colors.green.withValues(alpha: 0.07); cardColor = Colors.green.withValues(alpha: 0.07);
borderColor = Colors.green.withValues(alpha: 0.35); borderColor = Colors.green.withValues(alpha: 0.35);
titleColor = Colors.green.shade700; titleColor = Colors.green.shade700;
@ -1282,9 +1474,14 @@ class _ItemRow extends StatelessWidget {
), ),
), ),
], ],
// Dienstleistung (nicht-scanbar): Hinweis statt Scan/Manuell- // Set-Kopf, dessen Komponenten alle geladen sind → grünes
// Aktion. Steht an derselben Stelle wie der Manuell-Button. // „Komplett geladen". Sonst (nicht-scanbare Position, KEIN
if (scanNotRequired) ...[ // Set-Kopf) der normale „Kein Scanvorgang notwendig"-Hinweis;
// bei Set-Köpfen ist der unterdrückt (irreführend).
if (scanNotRequired && setParentComplete) ...[
const SizedBox(height: 8),
const _SetCompleteHint(),
] else if (scanNotRequired && !suppressScanHint) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
const _ScanNotRequiredHint(), const _ScanNotRequiredHint(),
], ],

View File

@ -10,6 +10,7 @@ import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart'; import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart'; import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart';
@ -74,8 +75,12 @@ class LoadingOverviewPage extends StatelessWidget {
); );
} }
if (tourState is! TourLoaded) { if (tourState is! TourLoaded) {
return const Scaffold( // Drawer auch im Ladezustand — analog zum TourEmpty-Branch,
body: Center(child: CircularProgressIndicator()), // damit der Fahrer beim Hängen nicht ohne Logout dasitzt.
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: AppBar(title: const Text('Beladung')),
body: const Center(child: CircularProgressIndicator()),
); );
} }
@ -866,6 +871,24 @@ class _EmptyOverview extends StatelessWidget {
'Keine Lieferungen zum Beladen', 'Keine Lieferungen zum Beladen',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 4),
Text(
'Für heute ist aktuell keine Tour verfügbar.',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: scheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
// Erneut die heutige Tour vom Backend laden. `LoadTour` blendet
// währenddessen den Seiten-Ladeindikator ein (TourLoading-Zweig) und
// landet danach wieder hier (TourEmpty) oder in der Tour-Ansicht.
FilledButton.tonalIcon(
onPressed: () => context.read<TourBloc>().add(const LoadTour()),
icon: const Icon(Icons.refresh),
label: const Text('Neu laden'),
),
], ],
), ),
); );

View File

@ -1,5 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:hl_lieferservice/data/network/backend_config.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart'; import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart';
import 'package:hl_lieferservice/feature/settings/bloc/settings_event.dart'; import 'package:hl_lieferservice/feature/settings/bloc/settings_event.dart';
import 'package:hl_lieferservice/feature/settings/bloc/settings_state.dart'; import 'package:hl_lieferservice/feature/settings/bloc/settings_state.dart';
@ -13,9 +18,63 @@ class SettingsPage extends StatefulWidget {
} }
class _SettingsPage extends State<SettingsPage> { class _SettingsPage extends State<SettingsPage> {
void _logout() {} /// Bestätigt das Abmelden und triggert dann `LogoutRequested` am
/// [AuthBloc]. Die Navigation zur LoginPage übernimmt der globale
/// `LoginEnforcer` automatisch beim Wechsel auf `Unauthenticated` —
/// gleicher Pfad wie der Abmelden-Button im Drawer
/// (`home_drawer._confirmLogout`).
///
/// Wording bewusst identisch zum Drawer-Dialog, damit es egal ist, von
/// welchem Einstiegspunkt der Fahrer kommt.
Future<void> _logout() async {
final authBloc = context.read<AuthBloc>();
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text("Abmelden"),
content: const Text(
"Möchten Sie sich wirklich abmelden? "
"Beim nächsten Start ist eine erneute Anmeldung erforderlich.",
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text("Abbrechen"),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text("Abmelden"),
),
],
),
);
if (confirmed != true) return;
authBloc.add(const LogoutRequested());
}
void _changePassword() {} // ─── Konto / Keycloak ─────────────────────────────────────────────────
/// Öffnet die Keycloak-Account-Konsole im externen Browser. Dort kann
/// der Fahrer sein Passwort ändern, hinterlegte E-Mail aktualisieren
/// und (sofern aktiviert) Zwei-Faktor-Authentifizierung verwalten.
///
/// URL-Pfad `/account/` ist die Standard-Route der Keycloak-26-Account-
/// Konsole; sie hängt sich an den Realm-Issuer aus [BackendConfig].
/// Externer Browser statt In-App-WebView, damit ggf. im Browser
/// gespeicherte Credentials / Authenticator-Apps weiter funktionieren.
Future<void> _openAccountConsole() async {
final issuer = BackendConfig.fromEnvironment.keycloakIssuerUrl;
final uri = Uri.parse('$issuer/account/');
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Kontoeinstellungen konnten nicht geöffnet werden: $uri'),
),
);
}
}
Widget _scanSettings() { Widget _scanSettings() {
return BlocBuilder<SettingsBloc, SettingsState>( return BlocBuilder<SettingsBloc, SettingsState>(
@ -97,15 +156,16 @@ class _SettingsPage extends State<SettingsPage> {
), ),
ListTile( ListTile(
title: const Text("Passwort öndern"), title: const Text("Konto verwalten"),
subtitle: const Text(
"Passwort, E-Mail und Sicherheitseinstellungen — öffnet die Konto-Seite im Browser",
),
trailing: Padding( trailing: Padding(
padding: const EdgeInsets.all(2), padding: const EdgeInsets.all(2),
child: IconButton( child: FilledButton.icon(
onPressed: _changePassword, onPressed: _openAccountConsole,
icon: FilledButton( icon: const Icon(Icons.open_in_new),
onPressed: _changePassword, label: const Text("Öffnen"),
child: const Text("Ändern"),
),
), ),
), ),
tileColor: Theme.of(context).colorScheme.onSecondary, tileColor: Theme.of(context).colorScheme.onSecondary,
@ -113,10 +173,10 @@ class _SettingsPage extends State<SettingsPage> {
ListTile( ListTile(
title: const Text("Ausloggen"), title: const Text("Ausloggen"),
trailing: IconButton( // Ganze Zeile tappbar machen — sonst muss der Fahrer das kleine
onPressed: _logout, // Icon präzise treffen.
icon: Icon(Icons.logout, color: Colors.redAccent), onTap: _logout,
), trailing: const Icon(Icons.logout, color: Colors.redAccent),
tileColor: Theme.of(context).colorScheme.onSecondary, tileColor: Theme.of(context).colorScheme.onSecondary,
), ),
], ],

View File

@ -120,6 +120,7 @@ class _DeliveryAppState extends State<DeliveryApp> {
), ),
], ],
child: MaterialApp( child: MaterialApp(
title: 'Holzleitner Auslieferung',
// Wrap the Navigator (not just the home route) so the loading // Wrap the Navigator (not just the home route) so the loading
// overlay covers every pushed route — DeliveryDetail, Cars, // overlay covers every pushed route — DeliveryDetail, Cars,
// dialogs, etc. — not only the initial home tree. // dialogs, etc. — not only the initial home tree.
@ -153,6 +154,7 @@ class _DeliveryAppState extends State<DeliveryApp> {
if (state is AppConfigLoadingFailed) { if (state is AppConfigLoadingFailed) {
return MaterialApp( return MaterialApp(
title: 'Holzleitner Auslieferung',
home: Scaffold( home: Scaffold(
body: Center(child: Text("Fehler beim Laden der Konfiguration")), body: Center(child: Text("Fehler beim Laden der Konfiguration")),
), ),
@ -160,6 +162,7 @@ class _DeliveryAppState extends State<DeliveryApp> {
} }
return MaterialApp( return MaterialApp(
title: 'Holzleitner Auslieferung',
home: Scaffold( home: Scaffold(
body: Center(child: const CircularProgressIndicator()), body: Center(child: const CircularProgressIndicator()),
), ),

View File

@ -8,6 +8,9 @@ import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_state.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart'; import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
/// Horizontaler Phasen-Stepper für die drei bzw. vier Schritte /// Horizontaler Phasen-Stepper für die drei bzw. vier Schritte
@ -33,6 +36,10 @@ import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
/// Fahrer beliebig zwischen besuchten Schritten hin- und herspringen. /// Fahrer beliebig zwischen besuchten Schritten hin- und herspringen.
/// * Noch nicht besuchte Phasen sind sperren (SnackBar-Hinweis). /// * Noch nicht besuchte Phasen sind sperren (SnackBar-Hinweis).
/// * Über das Menü-Icon links wird der Drawer geöffnet (Fahrzeuge/Settings). /// * Über das Menü-Icon links wird der Drawer geöffnet (Fahrzeuge/Settings).
/// * Daneben sitzt ein Reload-Button, der über `RefreshTour` einen
/// Backend-Refresh anstößt (z. B. wenn ein Disponent eine Lieferung
/// nachgetragen oder umverteilt hat). Während ein Refresh läuft,
/// ersetzt ein kleiner Spinner das Icon und der Button ist gesperrt.
/// * Rechts steht das Plate des aktiv gewählten Fahrzeugs. /// * Rechts steht das Plate des aktiv gewählten Fahrzeugs.
class PhaseStepper extends StatelessWidget { class PhaseStepper extends StatelessWidget {
const PhaseStepper({ const PhaseStepper({
@ -144,6 +151,7 @@ class PhaseStepper extends StatelessWidget {
tooltip: "Menü", tooltip: "Menü",
onPressed: () => Scaffold.of(context).openDrawer(), onPressed: () => Scaffold.of(context).openDrawer(),
), ),
_ReloadButton(onPrimary: onPrimary),
const Spacer(), const Spacer(),
BlocBuilder<CarSelectBloc, CarSelectState>( BlocBuilder<CarSelectBloc, CarSelectState>(
builder: (context, state) { builder: (context, state) {
@ -395,3 +403,47 @@ class _Connector extends StatelessWidget {
); );
} }
} }
/// Reload-Button in der oberen Reihe des [PhaseStepper]s. Feuert
/// `RefreshTour` am [TourBloc] — refresht im Hintergrund und behält den
/// aktuellen `TourLoaded`-Snapshot sichtbar (kein Flicker zurück auf den
/// Lade-Spinner). Während des Refreshs ersetzt eine kleine
/// Fortschrittsanzeige das Icon und der Button ist gesperrt, um
/// Doppel-Triggern zu verhindern.
class _ReloadButton extends StatelessWidget {
const _ReloadButton({required this.onPrimary});
final Color onPrimary;
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
// Nur neu rendern, wenn sich der Refresh-Status ändert — sonst
// läuft der Builder bei jedem Scan-Tick mit.
buildWhen: (prev, curr) {
final p = prev is TourLoaded && prev.isRefreshing;
final c = curr is TourLoaded && curr.isRefreshing;
return p != c || prev.runtimeType != curr.runtimeType;
},
builder: (context, state) {
final isRefreshing = state is TourLoaded && state.isRefreshing;
return IconButton(
tooltip: 'Tour aktualisieren',
onPressed: isRefreshing
? null
: () => context.read<TourBloc>().add(const RefreshTour()),
icon: isRefreshing
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: onPrimary,
),
)
: Icon(Icons.refresh, color: onPrimary),
);
},
);
}
}

View File

@ -177,6 +177,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.3" version: "2.0.3"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@ -374,6 +382,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.1.1" version: "9.1.1"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -599,6 +615,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
image_picker: image_picker:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -80,12 +80,21 @@ dev_dependencies:
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^5.0.0 flutter_lints: ^5.0.0
json_serializable: ^6.9.5 json_serializable: ^6.9.5
flutter_launcher_icons: ^0.14.4
# Generator wird über tool/generate_api_client.sh (Java-CLI) gefahren — # Generator wird über tool/generate_api_client.sh (Java-CLI) gefahren —
# kein build_runner-Hook, daher kein openapi_generator-Paket nötig. # kein build_runner-Hook, daher kein openapi_generator-Paket nötig.
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec
flutter_launcher_icons:
android: "launcher_icon"
ios: true
# iOS-Icons dürfen keinen Alpha-Kanal haben (sonst App-Store-Ablehnung).
remove_alpha_ios: true
image_path: "assets/launch_icon.png"
min_sdk_android: 21
# The following section is specific to Flutter packages. # The following section is specific to Flutter packages.
flutter: flutter:

View File

@ -20,7 +20,7 @@ import 'package:hl_lieferservice/data/network/dev_password_grant_token_provider.
import 'package:hl_lieferservice/data/network/holzleitner_api_factory.dart'; import 'package:hl_lieferservice/data/network/holzleitner_api_factory.dart';
Future<void> main() async { Future<void> main() async {
const config = BackendConfig.localDev; const config = BackendConfig.prod;
// Health geht ohne Auth — wir nutzen die generierte API-Klasse trotzdem, // Health geht ohne Auth — wir nutzen die generierte API-Klasse trotzdem,
// um zu zeigen, dass der Aufruf-Pfad funktioniert. // um zu zeigen, dass der Aufruf-Pfad funktioniert.