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" />
|
<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/ic_launcher"
|
||||||
android:networkSecurityConfig="@xml/network_security_config">
|
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>
|
<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(
|
||||||
|
|||||||
@ -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()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -223,15 +223,21 @@ 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) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
drawer: const HomeAppDrawer(),
|
||||||
appBar: AppBar(title: const Text('Sortieren')),
|
appBar: AppBar(title: const Text('Sortieren')),
|
||||||
body: _emptyState(),
|
body: _emptyState(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (state is! TourLoaded) {
|
if (state is! TourLoaded) {
|
||||||
return const Scaffold(
|
return Scaffold(
|
||||||
body: Center(child: CircularProgressIndicator()),
|
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_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
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,19 @@ 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
|
||||||
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user