Compare commits
15 Commits
a9bf8ecdd1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f12ad5d3c0 | |||
| 8b1b35b374 | |||
| 7e345bd71b | |||
| 4c6bef6897 | |||
| 6d2f496700 | |||
| 446cf73347 | |||
| 9ec3bba047 | |||
| 178edfdeed | |||
| 16648240cd | |||
| 3a937c317d | |||
| 7c362549ee | |||
| a206636ed0 | |||
| 467f4b4ed2 | |||
| 7544760c34 | |||
| f1e48cb177 |
@ -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"
|
||||||
|
|||||||
BIN
android/app/src/main/res/mipmap-hdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/launch_icon.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
@ -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++";
|
||||||
|
|||||||
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 14 KiB |
@ -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>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -73,60 +73,100 @@ 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<_DeliveryDetailScaffold> createState() =>
|
||||||
final theme = Theme.of(context);
|
_DeliveryDetailScaffoldState();
|
||||||
return BlocBuilder<TourBloc, TourState>(
|
}
|
||||||
builder: (context, tourState) {
|
|
||||||
if (tourState is! TourLoaded) {
|
class _DeliveryDetailScaffoldState extends State<_DeliveryDetailScaffold> {
|
||||||
return const Scaffold(
|
/// „Gearmt" = die Lieferung war während dieser Page-Session aktiv. Nur dann
|
||||||
body: Center(child: CircularProgressIndicator()),
|
/// 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).
|
||||||
final details = tourState.details;
|
bool _armed = false;
|
||||||
final delivery = _findDelivery(details);
|
bool _popped = false;
|
||||||
if (delivery == null) {
|
|
||||||
return Scaffold(
|
@override
|
||||||
appBar: AppBar(title: const Text('Lieferung')),
|
void initState() {
|
||||||
body: Center(
|
super.initState();
|
||||||
child: Text('Lieferung $deliveryId nicht in der Tour gefunden.'),
|
final s = context.read<TourBloc>().state;
|
||||||
),
|
if (s is TourLoaded && _findDelivery(s.details)?.state == DeliveryState.active) {
|
||||||
);
|
_armed = true;
|
||||||
}
|
}
|
||||||
final customer = details.customerOf(delivery);
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: theme.primaryColor,
|
|
||||||
foregroundColor: theme.colorScheme.onPrimary,
|
|
||||||
title: Text(customer?.name ?? 'Lieferung'),
|
|
||||||
),
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
const _StepHeader(),
|
|
||||||
const Divider(height: 1),
|
|
||||||
Expanded(
|
|
||||||
child: _StepBody(delivery: delivery, details: details),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
_BottomNav(delivery: delivery, details: details),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Delivery? _findDelivery(TourDetails details) {
|
Delivery? _findDelivery(TourDetails details) {
|
||||||
for (final d in details.deliveries) {
|
for (final d in details.deliveries) {
|
||||||
if (d.id == deliveryId) return d;
|
if (d.id == widget.deliveryId) return d;
|
||||||
}
|
}
|
||||||
return null;
|
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
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return BlocListener<TourBloc, TourState>(
|
||||||
|
listener: _onTourState,
|
||||||
|
child: BlocBuilder<TourBloc, TourState>(
|
||||||
|
builder: (context, tourState) {
|
||||||
|
if (tourState is! TourLoaded) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final details = tourState.details;
|
||||||
|
final delivery = _findDelivery(details);
|
||||||
|
if (delivery == null) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Lieferung')),
|
||||||
|
body: Center(
|
||||||
|
child: Text(
|
||||||
|
'Lieferung ${widget.deliveryId} nicht in der Tour gefunden.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final customer = details.customerOf(delivery);
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: theme.primaryColor,
|
||||||
|
foregroundColor: theme.colorScheme.onPrimary,
|
||||||
|
title: Text(customer?.name ?? 'Lieferung'),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
const _StepHeader(),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: _StepBody(delivery: delivery, details: details),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_BottomNav(delivery: delivery, details: details),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step-Header (Pills) ────────────────────────────────────────────────
|
// ─── Step-Header (Pills) ────────────────────────────────────────────────
|
||||||
|
|||||||
@ -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,53 +387,83 @@ 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)
|
||||||
trailing: Row(
|
_StatusLine(
|
||||||
mainAxisSize: MainAxisSize.min,
|
text: 'Nur über den Oberartikel entfernbar',
|
||||||
children: [
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
if (credited > 0)
|
|
||||||
IconButton(
|
|
||||||
// Wiederherstellen nur bei aktiver Lieferung — bei
|
|
||||||
// abgeschlossener/abgebrochener Lieferung gesperrt (greift auch
|
|
||||||
// backend-seitig, hier zusätzlich in der UI).
|
|
||||||
tooltip: deliveryActive
|
|
||||||
? 'Gutschrift zurücknehmen'
|
|
||||||
: 'Nur bei aktiver Lieferung',
|
|
||||||
icon: Icon(
|
|
||||||
Icons.restore,
|
|
||||||
color: deliveryActive
|
|
||||||
? theme.colorScheme.primary
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
onPressed: deliveryActive ? () => _restoreAll(context) : null,
|
|
||||||
),
|
),
|
||||||
if (!fullyRemoved)
|
// Set-Oberartikel: blockiert, solange eine Komponente noch nicht
|
||||||
IconButton.outlined(
|
// verladen ist.
|
||||||
tooltip: blockedByScan
|
if (hasComponents &&
|
||||||
? 'Erst scannen/verladen'
|
componentsBlocked &&
|
||||||
: (!deliveryActive
|
deliveryActive &&
|
||||||
? 'Nur bei aktiver Lieferung'
|
!fullyRemoved)
|
||||||
: 'Gutschrift / entfernen'),
|
_StatusLine(
|
||||||
style: ButtonStyle(
|
text: 'Erst alle Set-Teile verladen — dann ganzes Set entfernbar',
|
||||||
backgroundColor: WidgetStatePropertyAll(
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
canCredit
|
|
||||||
? Colors.redAccent
|
|
||||||
: theme.colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onPressed: canCredit
|
|
||||||
? () => _openCreditDialog(context, remaining: remaining)
|
|
||||||
: null,
|
|
||||||
icon: Icon(
|
|
||||||
Icons.delete,
|
|
||||||
color: canCredit
|
|
||||||
? theme.colorScheme.onPrimary
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
// 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,
|
||||||
|
children: [
|
||||||
|
if (credited > 0)
|
||||||
|
IconButton(
|
||||||
|
// Wiederherstellen nur bei aktiver Lieferung — bei
|
||||||
|
// abgeschlossener/abgebrochener Lieferung gesperrt (greift
|
||||||
|
// auch backend-seitig, hier zusätzlich in der UI).
|
||||||
|
tooltip: deliveryActive
|
||||||
|
? (hasComponents
|
||||||
|
? 'Set wiederherstellen'
|
||||||
|
: 'Gutschrift zurücknehmen')
|
||||||
|
: 'Nur bei aktiver Lieferung',
|
||||||
|
icon: Icon(
|
||||||
|
Icons.restore,
|
||||||
|
color: deliveryActive
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
onPressed: deliveryActive
|
||||||
|
? () => hasComponents
|
||||||
|
? _restoreSet(context)
|
||||||
|
: _restoreAll(context)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
if (!fullyRemoved)
|
||||||
|
IconButton.outlined(
|
||||||
|
tooltip: (blockedByScan || componentsBlocked)
|
||||||
|
? 'Erst scannen/verladen'
|
||||||
|
: (!deliveryActive
|
||||||
|
? 'Nur bei aktiver Lieferung'
|
||||||
|
: (hasComponents
|
||||||
|
? 'Ganzes Set entfernen'
|
||||||
|
: 'Gutschrift / entfernen')),
|
||||||
|
style: ButtonStyle(
|
||||||
|
backgroundColor: WidgetStatePropertyAll(
|
||||||
|
canCredit
|
||||||
|
? Colors.redAccent
|
||||||
|
: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: canCredit
|
||||||
|
? () => hasComponents
|
||||||
|
? _openSetRemoveDialog(context, remaining: remaining)
|
||||||
|
: _openCreditDialog(context, remaining: remaining)
|
||||||
|
: null,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.delete,
|
||||||
|
color: canCredit
|
||||||
|
? theme.colorScheme.onPrimary
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,16 +719,77 @@ class _ArticleList extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Card(
|
// Hat sich bei mindestens einem Artikel die tatsächliche Menge geändert
|
||||||
margin: EdgeInsets.zero,
|
// (entfernt oder teilweise gutgeschrieben)? Dann ein Banner mit Verweis
|
||||||
child: Column(
|
// auf Step 3 „Artikel", wo die Änderungen verwaltet werden.
|
||||||
children: [
|
final anyChanged = items.any(_quantityChanged);
|
||||||
for (int i = 0; i < items.length; i++) ...[
|
|
||||||
_ArticleRow(item: items[i], details: details),
|
return Column(
|
||||||
if (i < items.length - 1)
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
children: [
|
||||||
],
|
if (anyChanged) ...[
|
||||||
|
const _QuantityChangedBanner(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < items.length; i++) ...[
|
||||||
|
_ArticleRow(item: items[i], details: details),
|
||||||
|
if (i < items.length - 1)
|
||||||
|
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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,19 +837,47 @@ class _ArticleRow extends StatelessWidget {
|
|||||||
color: removed ? theme.colorScheme.onSurfaceVariant : null,
|
color: removed ? theme.colorScheme.onSurfaceVariant : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Column(
|
||||||
[
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
article?.articleNumber ?? item.articleId,
|
children: [
|
||||||
if (warehouse != null) warehouse.name,
|
Text(
|
||||||
if (!isScannable) 'Dienstleistung',
|
[
|
||||||
if (item.unitPrice > 0)
|
article?.articleNumber ?? item.articleId,
|
||||||
'${item.unitPrice.toStringAsFixed(2)} € / Stück',
|
if (warehouse != null) warehouse.name,
|
||||||
].join(' · '),
|
if (!isScannable) 'Dienstleistung',
|
||||||
style: TextStyle(
|
if (item.unitPrice > 0)
|
||||||
fontSize: 12,
|
'${item.unitPrice.toStringAsFixed(2)} € / Stück',
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
].join(' · '),
|
||||||
decoration: removed ? TextDecoration.lineThrough : null,
|
style: TextStyle(
|
||||||
),
|
fontSize: 12,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
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(
|
||||||
|
|||||||
@ -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,
|
'mehr änderbar.',
|
||||||
size: 16,
|
),
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
] else if (!hasOpenAmount) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(height: 8),
|
||||||
Expanded(
|
const _PickerHint(
|
||||||
child: Text(
|
text: 'Keine Zahlung mehr offen (bereits bezahlt) — '
|
||||||
'Lieferung abgeschlossen — Zahlungsmethode nicht '
|
'Auswahl der Zahlungsweise nicht erforderlich.',
|
||||||
'mehr änderbar.',
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.bodySmall
|
|
||||||
?.copyWith(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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()),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
24
pubspec.lock
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||