Compare commits

..

5 Commits

Author SHA1 Message Date
7c362549ee feat(settings): Konto-Konsole (Keycloak) oeffnen + Logout mit Bestaetigung
- 'Konto verwalten' oeffnet die Keycloak-Account-Konsole (issuer/account/) im externen Browser (Passwort/E-Mail/2FA)
- 'Ausloggen': ganze Zeile tappbar + Bestaetigungsdialog -> LogoutRequested

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

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

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

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

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

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -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>

View File

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

View File

@ -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());

View File

@ -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,

View File

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

View File

@ -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(

View File

@ -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()),
);
}

View File

@ -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()),
);
}

View File

@ -10,6 +10,7 @@ import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_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'),
),
],
),
);

View File

@ -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,
),
],

View File

@ -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()),
),

View File

@ -8,6 +8,9 @@ import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_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),
);
},
);
}
}

View File

@ -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:

View File

@ -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:

View File

@ -20,7 +20,7 @@ import 'package:hl_lieferservice/data/network/dev_password_grant_token_provider.
import 'package:hl_lieferservice/data/network/holzleitner_api_factory.dart';
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.