From f832b69b9db7bd9d54dd5eba3310b2ab415abc85 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Fri, 25 Sep 2026 13:06:49 +0200 Subject: [PATCH] feat(umgebung): Server-Umgebung im Login-Screen waehlbar (Produktion/Test) - Umgebungen (Name, API-URL, Keycloak-Issuer) kommen aus assets/hl_server_config.json statt hartcodiertem BackendConfig.prod; Testumgebung 192.168.1.9:3001 ist dort hinterlegt - AppBloc laedt die Config, aktiviert die gespeicherte Wahl (Fallback Default) und registriert das Networking; Wechsel persistiert per SharedPreferences und registriert neu - Bloc-Baum ist pro Umgebung gekeyt: beim Wechsel entstehen alle Blocs mit dem neuen API-Client neu - Login-Screen: Zahnrad oben rechts oeffnet Einstellungen mit Umgebungs- Cards (Name + IP:Port); Hinweis auf aktive Umgebung unter dem Button - Fahrzeugauswahl pro Umgebung getrennt (eigene DB = eigene Car-IDs) - Tests fuer Config-Parsing, Auswahl-Fallback, Card-Wechsel, Re-Registrierung Co-Authored-By: Claude Opus 5.5 --- assets/hl_server_config.json | 22 ++- lib/bloc/app_bloc.dart | 66 ++++++-- lib/bloc/app_events.dart | 11 +- lib/bloc/app_states.dart | 17 +- lib/data/network/backend_config.dart | 20 +-- lib/data/network/backend_environment.dart | 123 +++++++++++++++ lib/data/network/environment_repository.dart | 35 +++++ lib/data/network/network_locator.dart | 37 +++-- .../presentation/login_page.dart | 44 +++++- .../repository/car_selection_repository.dart | 19 ++- .../environment_settings_page.dart | 148 ++++++++++++++++++ .../settings/presentation/settings_page.dart | 3 +- lib/main.dart | 19 ++- lib/widget/app.dart | 17 +- test/environment_settings_page_test.dart | 113 +++++++++++++ test/server_catalog_test.dart | 73 +++++++++ tool/smoke_test_api.dart | 22 ++- 17 files changed, 717 insertions(+), 72 deletions(-) create mode 100644 lib/data/network/backend_environment.dart create mode 100644 lib/data/network/environment_repository.dart create mode 100644 lib/feature/settings/presentation/environment_settings_page.dart create mode 100644 test/environment_settings_page_test.dart create mode 100644 test/server_catalog_test.dart diff --git a/assets/hl_server_config.json b/assets/hl_server_config.json index d34e349..874eb1d 100644 --- a/assets/hl_server_config.json +++ b/assets/hl_server_config.json @@ -1,3 +1,21 @@ { - "backendUrl": "http://192.168.1.9:3000" -} \ No newline at end of file + "defaultEnvironment": "production", + "keycloak": { + "clientId": "holzleitner-app", + "redirectUrl": "holzleitner://oauth2redirect" + }, + "environments": [ + { + "id": "production", + "name": "Produktion", + "apiBaseUrl": "http://192.168.1.9:3000", + "keycloakIssuerUrl": "http://192.168.1.9:8080/realms/holzleitner" + }, + { + "id": "test", + "name": "Testumgebung", + "apiBaseUrl": "http://192.168.1.9:3001", + "keycloakIssuerUrl": "http://192.168.1.9:8080/realms/holzleitner" + } + ] +} diff --git a/lib/bloc/app_bloc.dart b/lib/bloc/app_bloc.dart index fcd1ddc..d8dc383 100644 --- a/lib/bloc/app_bloc.dart +++ b/lib/bloc/app_bloc.dart @@ -1,20 +1,64 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/bloc/app_events.dart'; import 'package:hl_lieferservice/bloc/app_states.dart'; +import 'package:hl_lieferservice/data/network/backend_config.dart'; +import 'package:hl_lieferservice/data/network/backend_environment.dart'; +import 'package:hl_lieferservice/data/network/environment_repository.dart'; + +/// Aktiviert das Networking für eine Umgebung (Registrierung im Locator). +typedef EnvironmentActivator = void Function(BackendConfig config); /// App-Bootstrap-Bloc. /// -/// Vor der Backend-Migration lud dieser Bloc eine `hl_server_config.json` aus -/// assets, parste daraus eine `backendUrl` und persistierte sie ins Dateisystem. -/// Mit dem Wechsel auf das Rust-Backend kommt die URL über `BackendConfig` -/// (compile-time, siehe `data/network/backend_config.dart`); der App-Bloc -/// emittiert jetzt nur noch sofort `AppConfigLoaded`, damit die UI ihre -/// üblichen Phasen-Übergänge behält. +/// Liest die Server-Umgebungen aus `assets/hl_server_config.json`, +/// bestimmt die aktive Umgebung (gespeicherte Wahl, sonst Default) und +/// registriert dafür das Networking. Ein Umgebungswechsel läuft über +/// denselben Weg; die UI baut ihren Bloc-Baum pro Umgebung neu auf. class AppBloc extends Bloc { - AppBloc() : super(const AppInitial()) { - on((event, emit) { - emit(const AppConfigLoading()); - emit(const AppConfigLoaded()); - }); + AppBloc({ + required EnvironmentRepository repository, + required EnvironmentActivator activate, + }) : _repository = repository, + _activate = activate, + super(const AppInitial()) { + on(_onLoad); + on(_onSwitch); + } + + final EnvironmentRepository _repository; + final EnvironmentActivator _activate; + + Future _onLoad(AppLoadConfig event, Emitter emit) async { + emit(const AppConfigLoading()); + try { + final catalog = await _repository.loadCatalog('assets/${event.path}'); + final storedId = await _repository.loadSelectedId(); + // Unbekannte ID (Umgebung aus der Config entfernt) → Default. + final active = catalog.byId(storedId) ?? catalog.defaultEnvironment; + _activate(catalog.configFor(active)); + emit(AppConfigLoaded(catalog: catalog, active: active)); + } catch (e, st) { + debugPrint('AppBloc: Server-Config nicht ladbar: $e\n$st'); + emit(AppConfigLoadingFailed( + message: 'Server-Konfiguration konnte nicht geladen werden.\n$e', + )); + } + } + + Future _onSwitch( + AppSwitchEnvironment event, + Emitter emit, + ) async { + final current = state; + if (current is! AppConfigLoaded) return; + if (current.active.id == event.environmentId) return; + + final BackendEnvironment? next = current.catalog.byId(event.environmentId); + if (next == null) return; + + await _repository.saveSelectedId(next.id); + _activate(current.catalog.configFor(next)); + emit(AppConfigLoaded(catalog: current.catalog, active: next)); } } diff --git a/lib/bloc/app_events.dart b/lib/bloc/app_events.dart index 1eb105a..6f5c214 100644 --- a/lib/bloc/app_events.dart +++ b/lib/bloc/app_events.dart @@ -1,7 +1,16 @@ abstract class AppEvents {} +/// Lädt die Server-Config aus den Assets und aktiviert die gespeicherte +/// (oder die Default-)Umgebung. [path] ist relativ zu `assets/`. class AppLoadConfig extends AppEvents { String path; AppLoadConfig({required this.path}); -} \ No newline at end of file +} + +/// Wechselt auf die Umgebung mit [environmentId] und speichert die Wahl. +class AppSwitchEnvironment extends AppEvents { + AppSwitchEnvironment({required this.environmentId}); + + final String environmentId; +} diff --git a/lib/bloc/app_states.dart b/lib/bloc/app_states.dart index 551db27..6ce2004 100644 --- a/lib/bloc/app_states.dart +++ b/lib/bloc/app_states.dart @@ -1,10 +1,9 @@ +import 'package:hl_lieferservice/data/network/backend_environment.dart'; + /// Lifecycle-States des App-Bootstraps. /// -/// Die alte `LocalDocuFrameConfiguration` mit `backendUrl` ist mit der -/// Backend-Migration entfallen — die App-Konfiguration kommt jetzt aus -/// `BackendConfig` (compile-time) und nicht mehr aus einer asset-JSON. -/// Das `AppConfigLoaded`-Signal bleibt als Marker, dass der App-Bootstrap -/// abgeschlossen ist (Networking ist registriert, Token-Provider steht). +/// `AppConfigLoaded` heißt: Server-Config ist gelesen, die aktive Umgebung +/// steht fest und das Networking dafür ist im Locator registriert. abstract class AppState { const AppState(); } @@ -18,7 +17,13 @@ class AppConfigLoading extends AppState { } class AppConfigLoaded extends AppState { - const AppConfigLoaded(); + const AppConfigLoaded({required this.catalog, required this.active}); + + /// Alle auswählbaren Umgebungen aus der Config-Datei. + final ServerCatalog catalog; + + /// Die Umgebung, gegen die die App gerade spricht. + final BackendEnvironment active; } class AppConfigLoadingFailed extends AppState { diff --git a/lib/data/network/backend_config.dart b/lib/data/network/backend_config.dart index 94edc3c..5d59c5c 100644 --- a/lib/data/network/backend_config.dart +++ b/lib/data/network/backend_config.dart @@ -1,9 +1,8 @@ /// Endpoint-Konfiguration für das Rust-Backend. /// -/// 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. +/// Wird nicht mehr im Code festgelegt, sondern aus der gewählten +/// Umgebung in `assets/hl_server_config.json` abgeleitet +/// (`ServerCatalog.configFor`). Die aktive Instanz liegt im GetIt-Locator. class BackendConfig { const BackendConfig({ required this.apiBaseUrl, @@ -37,17 +36,4 @@ class BackendConfig { /// dem `CFBundleURLSchemes`-Eintrag in `ios/Runner/Info.plist` /// matchen. final String keycloakRedirectUrl; - - /// 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', - ); - - /// 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; } diff --git a/lib/data/network/backend_environment.dart b/lib/data/network/backend_environment.dart new file mode 100644 index 0000000..a879110 --- /dev/null +++ b/lib/data/network/backend_environment.dart @@ -0,0 +1,123 @@ +import 'backend_config.dart'; + +/// Eine auswählbare Server-Umgebung (z. B. Produktion, Test). +/// +/// Quelle ist ausschließlich `assets/hl_server_config.json` — die App +/// kennt keine hartcodierten Server. Siehe [ServerCatalog]. +class BackendEnvironment { + const BackendEnvironment({ + required this.id, + required this.name, + required this.apiBaseUrl, + required this.keycloakIssuerUrl, + }); + + /// Stabiler Schlüssel; wird als Auswahl persistiert und trennt lokale, + /// umgebungsabhängige Daten (z. B. Fahrzeugauswahl). + final String id; + + /// Anzeigename in der Umgebungsauswahl. + final String name; + + /// Basis-URL der Rust-API (ohne abschließenden Slash). + final String apiBaseUrl; + + /// Realm-Issuer der Keycloak-Instanz dieser Umgebung. + final String keycloakIssuerUrl; + + /// „IP:Port“ der API für die Anzeige, z. B. `192.168.1.9:3001`. + String get hostAndPort { + final uri = Uri.parse(apiBaseUrl); + return uri.hasPort ? '${uri.host}:${uri.port}' : uri.host; + } + + factory BackendEnvironment.fromJson(Map json) { + return BackendEnvironment( + id: _requireString(json, 'id'), + name: _requireString(json, 'name'), + apiBaseUrl: _stripTrailingSlash(_requireString(json, 'apiBaseUrl')), + keycloakIssuerUrl: + _stripTrailingSlash(_requireString(json, 'keycloakIssuerUrl')), + ); + } +} + +/// Alle in der Config-Datei hinterlegten Umgebungen plus die +/// umgebungsübergreifenden Keycloak-Client-Daten. +/// +/// Client-Id und Redirect-URL sind global, weil sie an die native +/// Plattform-Konfiguration gebunden sind (Redirect-Scheme in +/// `build.gradle.kts` / `Info.plist`) und nicht pro Server variieren. +class ServerCatalog { + ServerCatalog({ + required this.environments, + required this.defaultEnvironmentId, + required this.keycloakClientId, + required this.keycloakRedirectUrl, + }) { + if (environments.isEmpty) { + throw const FormatException('Config enthält keine Umgebungen.'); + } + final ids = environments.map((e) => e.id).toSet(); + if (ids.length != environments.length) { + throw const FormatException('Umgebungs-IDs in der Config sind doppelt.'); + } + if (!ids.contains(defaultEnvironmentId)) { + throw FormatException( + 'defaultEnvironment "$defaultEnvironmentId" existiert nicht.', + ); + } + } + + final List environments; + final String defaultEnvironmentId; + final String keycloakClientId; + final String keycloakRedirectUrl; + + BackendEnvironment get defaultEnvironment => byId(defaultEnvironmentId)!; + + BackendEnvironment? byId(String? id) { + for (final env in environments) { + if (env.id == id) return env; + } + return null; + } + + /// Vollständige Netzwerk-Konfiguration für [environment]. + BackendConfig configFor(BackendEnvironment environment) { + return BackendConfig( + apiBaseUrl: environment.apiBaseUrl, + keycloakIssuerUrl: environment.keycloakIssuerUrl, + keycloakClientId: keycloakClientId, + keycloakRedirectUrl: keycloakRedirectUrl, + ); + } + + factory ServerCatalog.fromJson(Map json) { + final keycloak = json['keycloak']; + if (keycloak is! Map) { + throw const FormatException('Abschnitt "keycloak" fehlt in der Config.'); + } + final rawEnvironments = json['environments']; + if (rawEnvironments is! List) { + throw const FormatException('Liste "environments" fehlt in der Config.'); + } + return ServerCatalog( + environments: rawEnvironments + .map((e) => BackendEnvironment.fromJson(e as Map)) + .toList(growable: false), + defaultEnvironmentId: _requireString(json, 'defaultEnvironment'), + keycloakClientId: _requireString(keycloak, 'clientId'), + keycloakRedirectUrl: _requireString(keycloak, 'redirectUrl'), + ); + } +} + +String _requireString(Map json, String key) { + final value = json[key]; + if (value is String && value.trim().isNotEmpty) return value.trim(); + throw FormatException('Feld "$key" fehlt oder ist leer in der Config.'); +} + +String _stripTrailingSlash(String url) => + url.endsWith('/') ? url.substring(0, url.length - 1) : url; diff --git a/lib/data/network/environment_repository.dart b/lib/data/network/environment_repository.dart new file mode 100644 index 0000000..f9fc9f7 --- /dev/null +++ b/lib/data/network/environment_repository.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart' show rootBundle; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'backend_environment.dart'; + +/// Lädt die Umgebungsliste aus der Asset-Config und persistiert, welche +/// Umgebung der Nutzer gewählt hat. +/// +/// Bewusst wird nur die **Auswahl** gespeichert, nicht die Liste selbst: +/// So wirkt eine geänderte Config (neue IP, neue Umgebung) mit dem +/// nächsten App-Update, ohne dass veraltete Kopien im Gerät hängen. +class EnvironmentRepository { + static const _selectedKey = 'selected_backend_environment'; + + Future loadCatalog(String assetPath) async { + final raw = await rootBundle.loadString(assetPath); + final json = jsonDecode(raw); + if (json is! Map) { + throw const FormatException('Config ist kein JSON-Objekt.'); + } + return ServerCatalog.fromJson(json); + } + + Future loadSelectedId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_selectedKey); + } + + Future saveSelectedId(String id) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_selectedKey, id); + } +} diff --git a/lib/data/network/network_locator.dart b/lib/data/network/network_locator.dart index b554e19..335acf5 100644 --- a/lib/data/network/network_locator.dart +++ b/lib/data/network/network_locator.dart @@ -6,20 +6,18 @@ import 'backend_config.dart'; import 'holzleitner_api_factory.dart'; import 'keycloak_oidc_token_provider.dart'; -/// Registriert das HTTP-/API-Subsystem im globalen GetIt-Locator. +/// Registriert das HTTP-/API-Subsystem für [config] im globalen +/// GetIt-Locator. /// -/// Aufruf bewusst nicht im AppBloc-Lifecycle, sondern in `main()` vor -/// dem `runApp` — die API-Klassen sind über die gesamte App-Lebensdauer -/// stabil. -/// -/// Phase B: produktiver `KeycloakOidcTokenProvider`. Die alte -/// `DevPasswordGrantTokenProvider`-Implementation bleibt im Code (für -/// das reine dart-Smoke-Tool, siehe `tool/smoke_test_api.dart`). +/// Aufgerufen vom `AppBloc`, sobald die Umgebung aus der Config-Datei +/// feststeht — und erneut bei jedem Umgebungswechsel. Bereits registrierte +/// Instanzen werden dann ersetzt; die App baut ihren Bloc-Baum danach neu +/// auf, damit niemand mehr den alten Client festhält. void registerNetworking({ required GetIt locator, - BackendConfig config = BackendConfig.fromEnvironment, + required BackendConfig config, }) { - locator.registerSingleton(config); + _replace(locator, config); final provider = KeycloakOidcTokenProvider( config: config, @@ -29,13 +27,18 @@ void registerNetworking({ // Doppelt registrieren: einmal unter der konkreten Klasse (für // den AuthBloc, der Login/Logout/Restore aufruft) und einmal hinter // dem Interface (für den HTTP-Interceptor). - locator.registerSingleton(provider); - locator.registerSingleton(provider); + _replace(locator, provider); + _replace(locator, provider); - locator.registerSingleton( - buildHolzleitnerApi( - config: config, - tokenProvider: locator(), - ), + _replace( + locator, + buildHolzleitnerApi(config: config, tokenProvider: provider), ); } + +void _replace(GetIt locator, T instance) { + if (locator.isRegistered()) { + locator.unregister(); + } + locator.registerSingleton(instance); +} diff --git a/lib/feature/authentication/presentation/login_page.dart b/lib/feature/authentication/presentation/login_page.dart index 3898d6c..a529ca7 100644 --- a/lib/feature/authentication/presentation/login_page.dart +++ b/lib/feature/authentication/presentation/login_page.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hl_lieferservice/bloc/app_bloc.dart'; +import 'package:hl_lieferservice/bloc/app_states.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/authentication/bloc/auth_state.dart'; +import 'package:hl_lieferservice/feature/settings/presentation/environment_settings_page.dart'; /// Login-Page nach der Migration auf Keycloak OIDC (Phase B). /// @@ -28,7 +31,25 @@ class LoginPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(), + appBar: AppBar( + actions: [ + // Während eines laufenden Logins gesperrt: Ein Umgebungswechsel + // würde den Token-Provider mitten im Browser-Flow austauschen. + BlocBuilder( + builder: (context, state) => IconButton( + tooltip: 'Einstellungen', + icon: const Icon(Icons.settings), + onPressed: state is Authenticating + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const EnvironmentSettingsPage(), + ), + ), + ), + ), + ], + ), body: Column( children: [ if (sessionExpired) @@ -111,6 +132,7 @@ class LoginPage extends StatelessWidget { ), ), ), + const _ActiveEnvironmentHint(), ], ), ), @@ -120,3 +142,23 @@ class LoginPage extends StatelessWidget { ); } } + +/// Zeigt, gegen welchen Server die Anmeldung läuft — damit niemand +/// versehentlich in der Testumgebung ausliefert. +class _ActiveEnvironmentHint extends StatelessWidget { + const _ActiveEnvironmentHint(); + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + if (state is! AppConfigLoaded) return const SizedBox.shrink(); + final env = state.active; + return Text( + 'Umgebung: ${env.name} (${env.hostAndPort})', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } +} diff --git a/lib/feature/car_selection/repository/car_selection_repository.dart b/lib/feature/car_selection/repository/car_selection_repository.dart index ef145df..2cc265e 100644 --- a/lib/feature/car_selection/repository/car_selection_repository.dart +++ b/lib/feature/car_selection/repository/car_selection_repository.dart @@ -6,11 +6,22 @@ import 'package:shared_preferences/shared_preferences.dart'; /// (Strings). Alte Pre-Phase-D-Installations können noch int-Werte /// unter `_car_id` liegen haben — die werden beim Lesen /// stillschweigend ignoriert (Migration durch "neu auswählen"). +/// +/// Die Keys sind pro Server-Umgebung ([environmentId]) getrennt: Jede +/// Umgebung hat ihre eigene Datenbank und damit eigene Fahrzeug-IDs. +/// Eine Auswahl aus der Produktion darf in der Testumgebung nicht +/// wiederhergestellt werden (und umgekehrt). class CarSelectionRepository { - static String _keyDate(String userId) => 'car_selection_${userId}_date'; - static String _keyCarId(String userId) => 'car_selection_${userId}_car_id'; - static String _keyCarPlate(String userId) => - 'car_selection_${userId}_car_plate'; + CarSelectionRepository({required this.environmentId}); + + final String environmentId; + + String _keyDate(String userId) => + 'car_selection_${environmentId}_${userId}_date'; + String _keyCarId(String userId) => + 'car_selection_${environmentId}_${userId}_car_id'; + String _keyCarPlate(String userId) => + 'car_selection_${environmentId}_${userId}_car_plate'; Future getSelection(String userId) async { final prefs = await SharedPreferences.getInstance(); diff --git a/lib/feature/settings/presentation/environment_settings_page.dart b/lib/feature/settings/presentation/environment_settings_page.dart new file mode 100644 index 0000000..146e59f --- /dev/null +++ b/lib/feature/settings/presentation/environment_settings_page.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:hl_lieferservice/bloc/app_bloc.dart'; +import 'package:hl_lieferservice/bloc/app_events.dart'; +import 'package:hl_lieferservice/bloc/app_states.dart'; +import 'package:hl_lieferservice/data/network/backend_environment.dart'; + +/// Einstellungen vor dem Login: Auswahl der Server-Umgebung. +/// +/// Erreichbar über das Zahnrad im Login-Screen. Die Umgebungen stammen +/// aus `assets/hl_server_config.json`; die Wahl persistiert der +/// [AppBloc]. Nach dem Antippen einer anderen Umgebung schließt sich die +/// Seite und die App baut sich gegen den neuen Server neu auf. +class EnvironmentSettingsPage extends StatelessWidget { + const EnvironmentSettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Einstellungen')), + body: BlocBuilder( + builder: (context, state) { + if (state is! AppConfigLoaded) { + return const Center(child: CircularProgressIndicator()); + } + final catalog = state.catalog; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('Umgebung', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text( + 'Mit welchem Server sich die App verbindet.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + for (final env in catalog.environments) + _EnvironmentCard( + environment: env, + selected: env.id == state.active.id, + isDefault: env.id == catalog.defaultEnvironmentId, + onTap: () => _select(context, state.active, env), + ), + ], + ); + }, + ), + ); + } + + void _select( + BuildContext context, + BackendEnvironment active, + BackendEnvironment picked, + ) { + final appBloc = context.read(); + Navigator.of(context).pop(); + if (picked.id != active.id) { + appBloc.add(AppSwitchEnvironment(environmentId: picked.id)); + } + } +} + +class _EnvironmentCard extends StatelessWidget { + const _EnvironmentCard({ + required this.environment, + required this.selected, + required this.isDefault, + required this.onTap, + }); + + final BackendEnvironment environment; + final bool selected; + final bool isDefault; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Card( + margin: const EdgeInsets.only(bottom: 12), + clipBehavior: Clip.antiAlias, + elevation: selected ? 2 : 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: selected ? scheme.primary : scheme.outlineVariant, + width: selected ? 2 : 1, + ), + ), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon( + Icons.dns_outlined, + color: selected ? scheme.primary : scheme.onSurfaceVariant, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + environment.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + if (isDefault) ...[ + const SizedBox(width: 8), + Text( + 'Standard', + style: TextStyle( + fontSize: 12, + color: scheme.onSurfaceVariant, + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + environment.hostAndPort, + style: TextStyle( + fontFamily: 'monospace', + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (selected) Icon(Icons.check_circle, color: scheme.primary), + ], + ), + ), + ), + ); + } +} diff --git a/lib/feature/settings/presentation/settings_page.dart b/lib/feature/settings/presentation/settings_page.dart index e00bed8..67f8b3b 100644 --- a/lib/feature/settings/presentation/settings_page.dart +++ b/lib/feature/settings/presentation/settings_page.dart @@ -3,6 +3,7 @@ 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/main.dart' show locator; 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'; @@ -64,7 +65,7 @@ class _SettingsPage extends State { /// Externer Browser statt In-App-WebView, damit ggf. im Browser /// gespeicherte Credentials / Authenticator-Apps weiter funktionieren. Future _openAccountConsole() async { - final issuer = BackendConfig.fromEnvironment.keycloakIssuerUrl; + final issuer = locator().keycloakIssuerUrl; final uri = Uri.parse('$issuer/account/'); final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); if (!ok && mounted) { diff --git a/lib/main.dart b/lib/main.dart index cb2d848..938c175 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:get_it/get_it.dart'; import 'package:hl_lieferservice/bloc/app_bloc.dart'; import 'package:hl_lieferservice/bloc/app_events.dart'; import 'package:hl_lieferservice/data/cache/attachment_cache.dart'; +import 'package:hl_lieferservice/data/network/environment_repository.dart'; import 'package:hl_lieferservice/data/network/network_locator.dart'; import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart'; import 'package:hl_lieferservice/feature/settings/bloc/settings_event.dart'; @@ -12,19 +13,23 @@ import 'package:hl_lieferservice/widget/app.dart'; final locator = GetIt.instance; void main() { - // Backend-Migration Phase A: HTTP-Stack + Token-Provider registrieren, - // bevor irgendein Bloc starten kann. Wirft eine Compile-Sicherheit - // hin, dass `HolzleitnerApi` ab hier per `locator()` - // verfügbar ist. - registerNetworking(locator: locator); - + // Das Networking (HTTP-Stack + Token-Provider) registriert der AppBloc, + // sobald die Umgebung aus `assets/hl_server_config.json` feststeht. Alle + // Konsumenten von `locator()` entstehen erst danach + // (Bloc-Baum wird erst bei `AppConfigLoaded` gebaut). // Persistenter Vorschau-Cache für Attachment-Bilder. Über die gesamte // App-Lebensdauer stabil und zustandslos (das Verzeichnis löst er lazy // selbst auf), daher hier als Singleton. locator.registerSingleton(AttachmentCache()); runApp(MultiBlocProvider(providers: [ - BlocProvider(create: (context) => AppBloc(),), + BlocProvider( + create: (context) => AppBloc( + repository: EnvironmentRepository(), + activate: (config) => + registerNetworking(locator: locator, config: config), + ), + ), BlocProvider(create: (context) => SettingsBloc()) ], child: App())); } diff --git a/lib/widget/app.dart b/lib/widget/app.dart index 1010444..52dce90 100644 --- a/lib/widget/app.dart +++ b/lib/widget/app.dart @@ -44,6 +44,10 @@ class _DeliveryAppState extends State { builder: (context, state) { if (state is AppConfigLoaded) { return MultiBlocProvider( + // Pro Server-Umgebung ein eigener Baum: Beim Wechsel entstehen + // alle Blocs neu und holen sich den frisch registrierten + // API-Client. Kein Bloc hält Daten der alten Umgebung. + key: ValueKey('environment-${state.active.id}'), providers: [ BlocProvider(create: (context) => NavigationBloc()), BlocProvider(create: (context) => OperationBloc()), @@ -77,7 +81,11 @@ class _DeliveryAppState extends State { ), BlocProvider( create: (context) => - CarSelectBloc(repository: CarSelectionRepository()), + CarSelectBloc( + repository: CarSelectionRepository( + environmentId: state.active.id, + ), + ), ), BlocProvider( // Phase-D-Migration: produktive CarsRepository-Impl @@ -171,7 +179,12 @@ class _DeliveryAppState extends State { title: 'Holzleitner Auslieferung', debugShowCheckedModeBanner: false, home: Scaffold( - body: Center(child: Text("Fehler beim Laden der Konfiguration")), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(state.message, textAlign: TextAlign.center), + ), + ), ), ); } diff --git a/test/environment_settings_page_test.dart b/test/environment_settings_page_test.dart new file mode 100644 index 0000000..822ca3e --- /dev/null +++ b/test/environment_settings_page_test.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hl_lieferservice/bloc/app_bloc.dart'; +import 'package:hl_lieferservice/bloc/app_events.dart'; +import 'package:hl_lieferservice/bloc/app_states.dart'; +import 'package:hl_lieferservice/data/network/backend_config.dart'; +import 'package:hl_lieferservice/data/network/backend_environment.dart'; +import 'package:hl_lieferservice/data/network/environment_repository.dart'; +import 'package:hl_lieferservice/feature/settings/presentation/environment_settings_page.dart'; + +/// Liest die echte Asset-Config, speichert die Auswahl im Speicher. +class _FakeRepository extends EnvironmentRepository { + _FakeRepository(this.selectedId); + + String? selectedId; + + @override + Future loadCatalog(String assetPath) async => + ServerCatalog.fromJson( + jsonDecode(File(assetPath).readAsStringSync()) as Map, + ); + + @override + Future loadSelectedId() async => selectedId; + + @override + Future saveSelectedId(String id) async => selectedId = id; +} + +void main() { + late _FakeRepository repo; + late List activated; + late AppBloc bloc; + + setUp(() { + repo = _FakeRepository(null); + activated = []; + bloc = AppBloc(repository: repo, activate: activated.add); + }); + + tearDown(() => bloc.close()); + + test('ohne gespeicherte Wahl startet die Default-Umgebung', () async { + bloc.add(AppLoadConfig(path: 'hl_server_config.json')); + final state = await bloc.stream.firstWhere((s) => s is AppConfigLoaded) + as AppConfigLoaded; + + expect(state.active.id, 'production'); + expect(activated.single.apiBaseUrl, 'http://192.168.1.9:3000'); + }); + + test('gespeicherte, aber unbekannte Umgebung fällt auf Default', () async { + repo.selectedId = 'gibt-es-nicht'; + bloc.add(AppLoadConfig(path: 'hl_server_config.json')); + final state = await bloc.stream.firstWhere((s) => s is AppConfigLoaded) + as AppConfigLoaded; + + expect(state.active.id, 'production'); + }); + + testWidgets('Card antippen wechselt Umgebung, speichert und schließt', + (tester) async { + await tester.runAsync(() async { + bloc.add(AppLoadConfig(path: 'hl_server_config.json')); + await bloc.stream.firstWhere((s) => s is AppConfigLoaded); + }); + + await tester.pumpWidget( + BlocProvider.value( + value: bloc, + child: MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const EnvironmentSettingsPage(), + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + // Beide Umgebungen aus der Config als Card mit Name + IP:Port. + expect(find.text('Produktion'), findsOneWidget); + expect(find.text('192.168.1.9:3000'), findsOneWidget); + expect(find.text('Testumgebung'), findsOneWidget); + expect(find.text('192.168.1.9:3001'), findsOneWidget); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + + await tester.tap(find.text('Testumgebung')); + await tester.runAsync( + () => bloc.stream.firstWhere( + (s) => s is AppConfigLoaded && s.active.id == 'test', + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(EnvironmentSettingsPage), findsNothing); + expect(repo.selectedId, 'test'); + expect(activated.last.apiBaseUrl, 'http://192.168.1.9:3001'); + }); +} diff --git a/test/server_catalog_test.dart b/test/server_catalog_test.dart new file mode 100644 index 0000000..e49fe1d --- /dev/null +++ b/test/server_catalog_test.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; +import 'package:hl_lieferservice/data/network/backend_config.dart'; +import 'package:hl_lieferservice/data/network/network_locator.dart'; +import 'package:hl_lieferservice/data/network/backend_environment.dart'; + +void main() { + ServerCatalog load() => ServerCatalog.fromJson( + jsonDecode(File('assets/hl_server_config.json').readAsStringSync()) + as Map, + ); + + test('ausgelieferte Config enthält Produktion (Default) und Test', () { + final catalog = load(); + + expect(catalog.defaultEnvironment.id, 'production'); + expect(catalog.byId('production')!.hostAndPort, '192.168.1.9:3000'); + expect(catalog.byId('test')!.hostAndPort, '192.168.1.9:3001'); + expect(catalog.keycloakClientId, 'holzleitner-app'); + }); + + test('configFor übernimmt Umgebung und globale Keycloak-Daten', () { + final catalog = load(); + final config = catalog.configFor(catalog.byId('test')!); + + expect(config.apiBaseUrl, 'http://192.168.1.9:3001'); + expect(config.keycloakRedirectUrl, 'holzleitner://oauth2redirect'); + }); + + test('unbekannte Default-Umgebung wird abgelehnt', () { + expect( + () => ServerCatalog.fromJson({ + 'defaultEnvironment': 'nope', + 'keycloak': {'clientId': 'c', 'redirectUrl': 'r://x'}, + 'environments': [ + { + 'id': 'a', + 'name': 'A', + 'apiBaseUrl': 'http://h:1/', + 'keycloakIssuerUrl': 'http://k/realms/x', + }, + ], + }), + throwsFormatException, + ); + }); + + test('registerNetworking ersetzt beim Umgebungswechsel die Instanzen', () { + TestWidgetsFlutterBinding.ensureInitialized(); + final catalog = load(); + final locator = GetIt.asNewInstance(); + + registerNetworking( + locator: locator, + config: catalog.configFor(catalog.byId('production')!), + ); + final firstApi = locator(); + + registerNetworking( + locator: locator, + config: catalog.configFor(catalog.byId('test')!), + ); + + expect(locator().apiBaseUrl, 'http://192.168.1.9:3001'); + expect(identical(locator(), firstApi), isFalse); + expect(locator().dio.options.baseUrl, + 'http://192.168.1.9:3001'); + }); +} diff --git a/tool/smoke_test_api.dart b/tool/smoke_test_api.dart index 9b5f10d..a1e77ba 100644 --- a/tool/smoke_test_api.dart +++ b/tool/smoke_test_api.dart @@ -14,13 +14,29 @@ /// 2. /me/cars (mit Auth via DevPasswordGrantTokenProvider) library; +import 'dart:convert'; +import 'dart:io'; + import 'package:dio/dio.dart'; -import 'package:hl_lieferservice/data/network/backend_config.dart'; +import 'package:hl_lieferservice/data/network/backend_environment.dart'; import 'package:hl_lieferservice/data/network/dev_password_grant_token_provider.dart'; import 'package:hl_lieferservice/data/network/holzleitner_api_factory.dart'; -Future main() async { - const config = BackendConfig.prod; +Future main(List args) async { + // Server kommen aus derselben Config wie in der App. Umgebung per + // Argument wählbar: `dart run tool/smoke_test_api.dart test`. + final catalog = ServerCatalog.fromJson( + jsonDecode(File('assets/hl_server_config.json').readAsStringSync()) + as Map, + ); + final envId = args.isNotEmpty ? args.first : catalog.defaultEnvironmentId; + final env = catalog.byId(envId); + if (env == null) { + print('Unbekannte Umgebung "$envId".'); + return; + } + final config = catalog.configFor(env); + print('Umgebung: ${env.name} (${env.apiBaseUrl})'); // Health geht ohne Auth — wir nutzen die generierte API-Klasse trotzdem, // um zu zeigen, dass der Aufruf-Pfad funktioniert.