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

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