Compare commits
5 Commits
a9bf8ecdd1
...
7c362549ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c362549ee | |||
| a206636ed0 | |||
| 467f4b4ed2 | |||
| 7544760c34 | |||
| f1e48cb177 |
@ -15,7 +15,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:label="hl_lieferservice"
|
||||
android:label="Holzleitner Auslieferung"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
BIN
assets/launch_icon.png
Normal file
BIN
assets/launch_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Hl Lieferservice</string>
|
||||
<string>Holzleitner Auslieferung</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
||||
@ -1,16 +1,9 @@
|
||||
/// Endpoint-Konfiguration für das Rust-Backend.
|
||||
///
|
||||
/// Diese Übergangs-Konfiguration für die Backend-Migration wird in
|
||||
/// Phase D durch eine umfassendere Konfigurations-Ablösung verfeinert
|
||||
/// (Build-Time-Flavor pro Stage etc.).
|
||||
///
|
||||
/// **Werte für lokale Entwicklung:**
|
||||
/// * iOS-Simulator + macOS-Host: `http://localhost:...`
|
||||
/// * Android-Emulator: `http://10.0.2.2:...`
|
||||
/// * Echtes Gerät im LAN: `http://<host-IP>:...`
|
||||
///
|
||||
/// Default ist iOS-Simulator-tauglich; für Android-Build vor dem
|
||||
/// Compile umstellen oder per Build-Flag injizieren.
|
||||
/// Produktiv-Setup: Backend und Keycloak laufen auf dem Host `192.168.1.9`.
|
||||
/// Es gibt bewusst KEINE Dev-/localhost-/Tunnel-Profile und keine
|
||||
/// `HL_BACKEND`-Umschaltung mehr — die App zeigt immer auf Prod. So kann kein
|
||||
/// Build versehentlich auf `localhost` oder eine Dev-IP zeigen.
|
||||
class BackendConfig {
|
||||
const BackendConfig({
|
||||
required this.apiBaseUrl,
|
||||
@ -24,11 +17,11 @@ class BackendConfig {
|
||||
|
||||
/// Realm-Issuer ohne `/.well-known/...`-Suffix —
|
||||
/// `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
|
||||
/// 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;
|
||||
|
||||
/// Token-Endpoint des Realms — abgeleitet aus dem Issuer.
|
||||
@ -45,52 +38,16 @@ class BackendConfig {
|
||||
/// matchen.
|
||||
final String keycloakRedirectUrl;
|
||||
|
||||
/// Default-Konfiguration für lokale Entwicklung gegen das
|
||||
/// Docker-Compose-Setup (Postgres + Keycloak + Backend).
|
||||
static const BackendConfig localDev = BackendConfig(
|
||||
apiBaseUrl: 'http://192.168.0.138:3000',
|
||||
keycloakIssuerUrl: 'http://192.168.0.138:8080/realms/holzleitner',
|
||||
/// Produktiv-Konfiguration — einzige Quelle der Wahrheit.
|
||||
static const BackendConfig prod = BackendConfig(
|
||||
apiBaseUrl: 'http://192.168.1.9:3000',
|
||||
keycloakIssuerUrl: 'http://192.168.1.9:8080/realms/holzleitner',
|
||||
keycloakClientId: 'holzleitner-app',
|
||||
keycloakRedirectUrl: 'holzleitner://oauth2redirect',
|
||||
);
|
||||
|
||||
/// Konfiguration für USB-Tunnel via `adb reverse` — gedacht für Tests in
|
||||
/// fremden Netzwerken, in denen das Gerät den Mac nicht über eine LAN-IP
|
||||
/// erreicht. Alles zeigt auf `localhost`; der Traffic wird über den
|
||||
/// USB-Bus zum Host getunnelt.
|
||||
///
|
||||
/// **Setup vor dem Start (Gerät per USB angesteckt):**
|
||||
/// ```
|
||||
/// adb reverse tcp:3000 tcp:3000 # Rust-API
|
||||
/// adb reverse tcp:8080 tcp:8080 # Keycloak
|
||||
/// ```
|
||||
///
|
||||
/// **Backend-Voraussetzungen**, damit das OIDC-Login funktioniert:
|
||||
/// * Backend-Env `KEYCLOAK_ISSUER_URL=http://localhost:8080/realms/holzleitner`
|
||||
/// (muss exakt mit [keycloakIssuerUrl] matchen, sonst 401 `invalid issuer`).
|
||||
/// * Keycloak muss den Issuer als `localhost` ausgeben — z. B. via
|
||||
/// `KC_HOSTNAME_URL=http://localhost:8080` (oder Frontend-URL im Realm),
|
||||
/// sonst prägt es den Container-Hostnamen ins `iss`-Claim.
|
||||
/// * Der `holzleitner://oauth2redirect`-Redirect bleibt unverändert (das
|
||||
/// Custom-Scheme ist netzwerk-unabhängig).
|
||||
///
|
||||
/// Aktivieren ohne Code-Edit:
|
||||
/// ```
|
||||
/// flutter run --dart-define=HL_BACKEND=usb
|
||||
/// ```
|
||||
static const BackendConfig usbReverse = BackendConfig(
|
||||
apiBaseUrl: 'http://localhost:3000',
|
||||
keycloakIssuerUrl: 'http://localhost:8080/realms/holzleitner',
|
||||
keycloakClientId: 'holzleitner-app',
|
||||
keycloakRedirectUrl: 'holzleitner://oauth2redirect',
|
||||
);
|
||||
|
||||
/// Wählt die Config anhand des Compile-Time-Flags `HL_BACKEND`:
|
||||
/// * `usb` → [usbReverse] (adb-reverse-Tunnel über localhost)
|
||||
/// * sonst → [localDev] (LAN-IP, Default)
|
||||
///
|
||||
/// So muss für einen Netzwerkwechsel nur das Build-Flag gesetzt werden,
|
||||
/// nicht der Quellcode angefasst.
|
||||
static const BackendConfig fromEnvironment =
|
||||
String.fromEnvironment('HL_BACKEND') == 'usb' ? usbReverse : localDev;
|
||||
/// Aktive Konfiguration. Früher per `--dart-define=HL_BACKEND` zwischen
|
||||
/// Dev-Profilen (usb-Tunnel / LAN-IP) umschaltbar — entfernt. Das Flag wird
|
||||
/// jetzt ignoriert; es zählt immer [prod].
|
||||
static const BackendConfig fromEnvironment = prod;
|
||||
}
|
||||
|
||||
@ -48,15 +48,32 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
|
||||
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(
|
||||
LoginRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(Authenticating());
|
||||
await tokenProvider.login();
|
||||
await tokenProvider.login().timeout(_loginTimeout);
|
||||
// Erfolg landet via Stream-Subscription als
|
||||
// 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) {
|
||||
debugPrint('Login fehlgeschlagen: $err\n$st');
|
||||
emit(Unauthenticated());
|
||||
|
||||
@ -10,7 +10,14 @@ class AuthBootstrapping extends AuthState {}
|
||||
|
||||
class Unauthenticated extends AuthState {
|
||||
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,
|
||||
|
||||
@ -28,8 +28,11 @@ class LoginEnforcer extends StatelessWidget {
|
||||
if (state is AuthBootstrapping) {
|
||||
return const _AuthBootstrapSplash();
|
||||
}
|
||||
final expired = state is Unauthenticated && state.sessionExpired;
|
||||
return LoginPage(sessionExpired: expired);
|
||||
final unauth = state is Unauthenticated ? state : null;
|
||||
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
|
||||
/// zurück, der Code wird gegen Tokens getauscht.
|
||||
class LoginPage extends StatelessWidget {
|
||||
const LoginPage({super.key, this.sessionExpired = false});
|
||||
const LoginPage({
|
||||
super.key,
|
||||
this.sessionExpired = false,
|
||||
this.loginTimedOut = false,
|
||||
});
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@ -36,6 +45,20 @@ class LoginPage extends StatelessWidget {
|
||||
),
|
||||
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(
|
||||
child: Center(
|
||||
child: Column(
|
||||
|
||||
@ -374,6 +374,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
|
||||
}
|
||||
if (state is TourEmpty) {
|
||||
return Scaffold(
|
||||
drawer: const HomeAppDrawer(),
|
||||
appBar: AppBar(title: const Text('Lieferungen auswählen')),
|
||||
body: const Center(
|
||||
child: Padding(
|
||||
@ -388,8 +389,11 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
|
||||
);
|
||||
}
|
||||
if (state is! TourLoaded) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
// Drawer auch hier — Fahrer soll im Lade-Hang ausloggen können.
|
||||
return Scaffold(
|
||||
drawer: const HomeAppDrawer(),
|
||||
appBar: AppBar(title: const Text('Lieferungen auswählen')),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -223,15 +223,21 @@ class _DeliverySortPageState extends State<DeliverySortPage> {
|
||||
}
|
||||
},
|
||||
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) {
|
||||
return Scaffold(
|
||||
drawer: const HomeAppDrawer(),
|
||||
appBar: AppBar(title: const Text('Sortieren')),
|
||||
body: _emptyState(),
|
||||
);
|
||||
}
|
||||
if (state is! TourLoaded) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
return Scaffold(
|
||||
drawer: const HomeAppDrawer(),
|
||||
appBar: AppBar(title: const Text('Sortieren')),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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_event.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/overview/presentation/delivery_fail_page.dart';
|
||||
@ -74,8 +75,12 @@ class LoadingOverviewPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
if (tourState is! TourLoaded) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
// Drawer auch im Ladezustand — analog zum TourEmpty-Branch,
|
||||
// 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',
|
||||
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_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_event.dart';
|
||||
import 'package:hl_lieferservice/feature/settings/bloc/settings_state.dart';
|
||||
@ -13,9 +18,63 @@ class SettingsPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
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() {
|
||||
return BlocBuilder<SettingsBloc, SettingsState>(
|
||||
@ -97,15 +156,16 @@ class _SettingsPage extends State<SettingsPage> {
|
||||
),
|
||||
|
||||
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(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: IconButton(
|
||||
onPressed: _changePassword,
|
||||
icon: FilledButton(
|
||||
onPressed: _changePassword,
|
||||
child: const Text("Ändern"),
|
||||
),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _openAccountConsole,
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text("Öffnen"),
|
||||
),
|
||||
),
|
||||
tileColor: Theme.of(context).colorScheme.onSecondary,
|
||||
@ -113,10 +173,10 @@ class _SettingsPage extends State<SettingsPage> {
|
||||
|
||||
ListTile(
|
||||
title: const Text("Ausloggen"),
|
||||
trailing: IconButton(
|
||||
onPressed: _logout,
|
||||
icon: Icon(Icons.logout, color: Colors.redAccent),
|
||||
),
|
||||
// Ganze Zeile tappbar machen — sonst muss der Fahrer das kleine
|
||||
// Icon präzise treffen.
|
||||
onTap: _logout,
|
||||
trailing: const Icon(Icons.logout, color: Colors.redAccent),
|
||||
tileColor: Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
],
|
||||
|
||||
@ -120,6 +120,7 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'Holzleitner Auslieferung',
|
||||
// Wrap the Navigator (not just the home route) so the loading
|
||||
// overlay covers every pushed route — DeliveryDetail, Cars,
|
||||
// dialogs, etc. — not only the initial home tree.
|
||||
@ -153,6 +154,7 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
||||
|
||||
if (state is AppConfigLoadingFailed) {
|
||||
return MaterialApp(
|
||||
title: 'Holzleitner Auslieferung',
|
||||
home: Scaffold(
|
||||
body: Center(child: Text("Fehler beim Laden der Konfiguration")),
|
||||
),
|
||||
@ -160,6 +162,7 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
||||
}
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Holzleitner Auslieferung',
|
||||
home: Scaffold(
|
||||
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_event.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';
|
||||
|
||||
/// 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.
|
||||
/// * Noch nicht besuchte Phasen sind sperren (SnackBar-Hinweis).
|
||||
/// * Ü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.
|
||||
class PhaseStepper extends StatelessWidget {
|
||||
const PhaseStepper({
|
||||
@ -144,6 +151,7 @@ class PhaseStepper extends StatelessWidget {
|
||||
tooltip: "Menü",
|
||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||
),
|
||||
_ReloadButton(onPrimary: onPrimary),
|
||||
const Spacer(),
|
||||
BlocBuilder<CarSelectBloc, CarSelectState>(
|
||||
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
24
pubspec.lock
@ -177,6 +177,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -374,6 +382,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -599,6 +615,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@ -80,12 +80,19 @@ dev_dependencies:
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^5.0.0
|
||||
json_serializable: ^6.9.5
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
# Generator wird über tool/generate_api_client.sh (Java-CLI) gefahren —
|
||||
# kein build_runner-Hook, daher kein openapi_generator-Paket nötig.
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: "launcher_icon"
|
||||
ios: true
|
||||
image_path: "assets/launch_icon.png"
|
||||
min_sdk_android: 21
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
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';
|
||||
|
||||
Future<void> main() async {
|
||||
const config = BackendConfig.localDev;
|
||||
const config = BackendConfig.prod;
|
||||
|
||||
// Health geht ohne Auth — wir nutzen die generierte API-Klasse trotzdem,
|
||||
// um zu zeigen, dass der Aufruf-Pfad funktioniert.
|
||||
|
||||
Reference in New Issue
Block a user