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:
Dennis Nemec
2026-09-25 13:06:49 +02:00
parent c239cdfd3e
commit f832b69b9d
17 changed files with 717 additions and 72 deletions

View File

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

View 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;

View 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);
}
}

View File

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