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 <noreply@anthropic.com>
This commit is contained in:
@ -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;
|
||||
}
|
||||
|
||||
123
lib/data/network/backend_environment.dart
Normal file
123
lib/data/network/backend_environment.dart
Normal file
@ -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<String, dynamic> 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<BackendEnvironment> 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<String, dynamic> json) {
|
||||
final keycloak = json['keycloak'];
|
||||
if (keycloak is! Map<String, dynamic>) {
|
||||
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<String, dynamic>))
|
||||
.toList(growable: false),
|
||||
defaultEnvironmentId: _requireString(json, 'defaultEnvironment'),
|
||||
keycloakClientId: _requireString(keycloak, 'clientId'),
|
||||
keycloakRedirectUrl: _requireString(keycloak, 'redirectUrl'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _requireString(Map<String, dynamic> 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;
|
||||
35
lib/data/network/environment_repository.dart
Normal file
35
lib/data/network/environment_repository.dart
Normal file
@ -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<ServerCatalog> loadCatalog(String assetPath) async {
|
||||
final raw = await rootBundle.loadString(assetPath);
|
||||
final json = jsonDecode(raw);
|
||||
if (json is! Map<String, dynamic>) {
|
||||
throw const FormatException('Config ist kein JSON-Objekt.');
|
||||
}
|
||||
return ServerCatalog.fromJson(json);
|
||||
}
|
||||
|
||||
Future<String?> loadSelectedId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_selectedKey);
|
||||
}
|
||||
|
||||
Future<void> saveSelectedId(String id) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_selectedKey, id);
|
||||
}
|
||||
}
|
||||
@ -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<BackendConfig>(config);
|
||||
_replace<BackendConfig>(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<KeycloakOidcTokenProvider>(provider);
|
||||
locator.registerSingleton<AuthTokenProvider>(provider);
|
||||
_replace<KeycloakOidcTokenProvider>(locator, provider);
|
||||
_replace<AuthTokenProvider>(locator, provider);
|
||||
|
||||
locator.registerSingleton<HolzleitnerApi>(
|
||||
buildHolzleitnerApi(
|
||||
config: config,
|
||||
tokenProvider: locator<AuthTokenProvider>(),
|
||||
),
|
||||
_replace<HolzleitnerApi>(
|
||||
locator,
|
||||
buildHolzleitnerApi(config: config, tokenProvider: provider),
|
||||
);
|
||||
}
|
||||
|
||||
void _replace<T extends Object>(GetIt locator, T instance) {
|
||||
if (locator.isRegistered<T>()) {
|
||||
locator.unregister<T>();
|
||||
}
|
||||
locator.registerSingleton<T>(instance);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user