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,3 +1,21 @@
|
|||||||
{
|
{
|
||||||
"backendUrl": "http://192.168.1.9:3000"
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@ -1,20 +1,64 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:hl_lieferservice/bloc/app_events.dart';
|
import 'package:hl_lieferservice/bloc/app_events.dart';
|
||||||
import 'package:hl_lieferservice/bloc/app_states.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.
|
/// App-Bootstrap-Bloc.
|
||||||
///
|
///
|
||||||
/// Vor der Backend-Migration lud dieser Bloc eine `hl_server_config.json` aus
|
/// Liest die Server-Umgebungen aus `assets/hl_server_config.json`,
|
||||||
/// assets, parste daraus eine `backendUrl` und persistierte sie ins Dateisystem.
|
/// bestimmt die aktive Umgebung (gespeicherte Wahl, sonst Default) und
|
||||||
/// Mit dem Wechsel auf das Rust-Backend kommt die URL über `BackendConfig`
|
/// registriert dafür das Networking. Ein Umgebungswechsel läuft über
|
||||||
/// (compile-time, siehe `data/network/backend_config.dart`); der App-Bloc
|
/// denselben Weg; die UI baut ihren Bloc-Baum pro Umgebung neu auf.
|
||||||
/// emittiert jetzt nur noch sofort `AppConfigLoaded`, damit die UI ihre
|
|
||||||
/// üblichen Phasen-Übergänge behält.
|
|
||||||
class AppBloc extends Bloc<AppEvents, AppState> {
|
class AppBloc extends Bloc<AppEvents, AppState> {
|
||||||
AppBloc() : super(const AppInitial()) {
|
AppBloc({
|
||||||
on<AppLoadConfig>((event, emit) {
|
required EnvironmentRepository repository,
|
||||||
|
required EnvironmentActivator activate,
|
||||||
|
}) : _repository = repository,
|
||||||
|
_activate = activate,
|
||||||
|
super(const AppInitial()) {
|
||||||
|
on<AppLoadConfig>(_onLoad);
|
||||||
|
on<AppSwitchEnvironment>(_onSwitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
final EnvironmentRepository _repository;
|
||||||
|
final EnvironmentActivator _activate;
|
||||||
|
|
||||||
|
Future<void> _onLoad(AppLoadConfig event, Emitter<AppState> emit) async {
|
||||||
emit(const AppConfigLoading());
|
emit(const AppConfigLoading());
|
||||||
emit(const AppConfigLoaded());
|
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<void> _onSwitch(
|
||||||
|
AppSwitchEnvironment event,
|
||||||
|
Emitter<AppState> 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,16 @@
|
|||||||
abstract class AppEvents {}
|
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 {
|
class AppLoadConfig extends AppEvents {
|
||||||
String path;
|
String path;
|
||||||
|
|
||||||
AppLoadConfig({required this.path});
|
AppLoadConfig({required this.path});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wechselt auf die Umgebung mit [environmentId] und speichert die Wahl.
|
||||||
|
class AppSwitchEnvironment extends AppEvents {
|
||||||
|
AppSwitchEnvironment({required this.environmentId});
|
||||||
|
|
||||||
|
final String environmentId;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
|
import 'package:hl_lieferservice/data/network/backend_environment.dart';
|
||||||
|
|
||||||
/// Lifecycle-States des App-Bootstraps.
|
/// Lifecycle-States des App-Bootstraps.
|
||||||
///
|
///
|
||||||
/// Die alte `LocalDocuFrameConfiguration` mit `backendUrl` ist mit der
|
/// `AppConfigLoaded` heißt: Server-Config ist gelesen, die aktive Umgebung
|
||||||
/// Backend-Migration entfallen — die App-Konfiguration kommt jetzt aus
|
/// steht fest und das Networking dafür ist im Locator registriert.
|
||||||
/// `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).
|
|
||||||
abstract class AppState {
|
abstract class AppState {
|
||||||
const AppState();
|
const AppState();
|
||||||
}
|
}
|
||||||
@ -18,7 +17,13 @@ class AppConfigLoading extends AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AppConfigLoaded 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 {
|
class AppConfigLoadingFailed extends AppState {
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
/// Endpoint-Konfiguration für das Rust-Backend.
|
/// Endpoint-Konfiguration für das Rust-Backend.
|
||||||
///
|
///
|
||||||
/// Produktiv-Setup: Backend und Keycloak laufen auf dem Host `192.168.1.9`.
|
/// Wird nicht mehr im Code festgelegt, sondern aus der gewählten
|
||||||
/// Es gibt bewusst KEINE Dev-/localhost-/Tunnel-Profile und keine
|
/// Umgebung in `assets/hl_server_config.json` abgeleitet
|
||||||
/// `HL_BACKEND`-Umschaltung mehr — die App zeigt immer auf Prod. So kann kein
|
/// (`ServerCatalog.configFor`). Die aktive Instanz liegt im GetIt-Locator.
|
||||||
/// Build versehentlich auf `localhost` oder eine Dev-IP zeigen.
|
|
||||||
class BackendConfig {
|
class BackendConfig {
|
||||||
const BackendConfig({
|
const BackendConfig({
|
||||||
required this.apiBaseUrl,
|
required this.apiBaseUrl,
|
||||||
@ -37,17 +36,4 @@ class BackendConfig {
|
|||||||
/// dem `CFBundleURLSchemes`-Eintrag in `ios/Runner/Info.plist`
|
/// dem `CFBundleURLSchemes`-Eintrag in `ios/Runner/Info.plist`
|
||||||
/// matchen.
|
/// matchen.
|
||||||
final String keycloakRedirectUrl;
|
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 'holzleitner_api_factory.dart';
|
||||||
import 'keycloak_oidc_token_provider.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
|
/// Aufgerufen vom `AppBloc`, sobald die Umgebung aus der Config-Datei
|
||||||
/// dem `runApp` — die API-Klassen sind über die gesamte App-Lebensdauer
|
/// feststeht — und erneut bei jedem Umgebungswechsel. Bereits registrierte
|
||||||
/// stabil.
|
/// Instanzen werden dann ersetzt; die App baut ihren Bloc-Baum danach neu
|
||||||
///
|
/// auf, damit niemand mehr den alten Client festhält.
|
||||||
/// Phase B: produktiver `KeycloakOidcTokenProvider`. Die alte
|
|
||||||
/// `DevPasswordGrantTokenProvider`-Implementation bleibt im Code (für
|
|
||||||
/// das reine dart-Smoke-Tool, siehe `tool/smoke_test_api.dart`).
|
|
||||||
void registerNetworking({
|
void registerNetworking({
|
||||||
required GetIt locator,
|
required GetIt locator,
|
||||||
BackendConfig config = BackendConfig.fromEnvironment,
|
required BackendConfig config,
|
||||||
}) {
|
}) {
|
||||||
locator.registerSingleton<BackendConfig>(config);
|
_replace<BackendConfig>(locator, config);
|
||||||
|
|
||||||
final provider = KeycloakOidcTokenProvider(
|
final provider = KeycloakOidcTokenProvider(
|
||||||
config: config,
|
config: config,
|
||||||
@ -29,13 +27,18 @@ void registerNetworking({
|
|||||||
// Doppelt registrieren: einmal unter der konkreten Klasse (für
|
// Doppelt registrieren: einmal unter der konkreten Klasse (für
|
||||||
// den AuthBloc, der Login/Logout/Restore aufruft) und einmal hinter
|
// den AuthBloc, der Login/Logout/Restore aufruft) und einmal hinter
|
||||||
// dem Interface (für den HTTP-Interceptor).
|
// dem Interface (für den HTTP-Interceptor).
|
||||||
locator.registerSingleton<KeycloakOidcTokenProvider>(provider);
|
_replace<KeycloakOidcTokenProvider>(locator, provider);
|
||||||
locator.registerSingleton<AuthTokenProvider>(provider);
|
_replace<AuthTokenProvider>(locator, provider);
|
||||||
|
|
||||||
locator.registerSingleton<HolzleitnerApi>(
|
_replace<HolzleitnerApi>(
|
||||||
buildHolzleitnerApi(
|
locator,
|
||||||
config: config,
|
buildHolzleitnerApi(config: config, tokenProvider: provider),
|
||||||
tokenProvider: locator<AuthTokenProvider>(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _replace<T extends Object>(GetIt locator, T instance) {
|
||||||
|
if (locator.isRegistered<T>()) {
|
||||||
|
locator.unregister<T>();
|
||||||
|
}
|
||||||
|
locator.registerSingleton<T>(instance);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.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/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).
|
/// Login-Page nach der Migration auf Keycloak OIDC (Phase B).
|
||||||
///
|
///
|
||||||
@ -28,7 +31,25 @@ class LoginPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
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<AuthBloc, AuthState>(
|
||||||
|
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(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
if (sessionExpired)
|
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<AppBloc>().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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -6,11 +6,22 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
/// (Strings). Alte Pre-Phase-D-Installations können noch int-Werte
|
/// (Strings). Alte Pre-Phase-D-Installations können noch int-Werte
|
||||||
/// unter `_car_id` liegen haben — die werden beim Lesen
|
/// unter `_car_id` liegen haben — die werden beim Lesen
|
||||||
/// stillschweigend ignoriert (Migration durch "neu auswählen").
|
/// 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 {
|
class CarSelectionRepository {
|
||||||
static String _keyDate(String userId) => 'car_selection_${userId}_date';
|
CarSelectionRepository({required this.environmentId});
|
||||||
static String _keyCarId(String userId) => 'car_selection_${userId}_car_id';
|
|
||||||
static String _keyCarPlate(String userId) =>
|
final String environmentId;
|
||||||
'car_selection_${userId}_car_plate';
|
|
||||||
|
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<CarSelection?> getSelection(String userId) async {
|
Future<CarSelection?> getSelection(String userId) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|||||||
148
lib/feature/settings/presentation/environment_settings_page.dart
Normal file
148
lib/feature/settings/presentation/environment_settings_page.dart
Normal file
@ -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<AppBloc, AppState>(
|
||||||
|
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<AppBloc>();
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import 'package:hl_lieferservice/data/network/backend_config.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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.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_bloc.dart';
|
||||||
@ -64,7 +65,7 @@ class _SettingsPage extends State<SettingsPage> {
|
|||||||
/// Externer Browser statt In-App-WebView, damit ggf. im Browser
|
/// Externer Browser statt In-App-WebView, damit ggf. im Browser
|
||||||
/// gespeicherte Credentials / Authenticator-Apps weiter funktionieren.
|
/// gespeicherte Credentials / Authenticator-Apps weiter funktionieren.
|
||||||
Future<void> _openAccountConsole() async {
|
Future<void> _openAccountConsole() async {
|
||||||
final issuer = BackendConfig.fromEnvironment.keycloakIssuerUrl;
|
final issuer = locator<BackendConfig>().keycloakIssuerUrl;
|
||||||
final uri = Uri.parse('$issuer/account/');
|
final uri = Uri.parse('$issuer/account/');
|
||||||
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
if (!ok && mounted) {
|
if (!ok && mounted) {
|
||||||
|
|||||||
@ -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_bloc.dart';
|
||||||
import 'package:hl_lieferservice/bloc/app_events.dart';
|
import 'package:hl_lieferservice/bloc/app_events.dart';
|
||||||
import 'package:hl_lieferservice/data/cache/attachment_cache.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/data/network/network_locator.dart';
|
||||||
import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.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_event.dart';
|
||||||
@ -12,19 +13,23 @@ import 'package:hl_lieferservice/widget/app.dart';
|
|||||||
final locator = GetIt.instance;
|
final locator = GetIt.instance;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// Backend-Migration Phase A: HTTP-Stack + Token-Provider registrieren,
|
// Das Networking (HTTP-Stack + Token-Provider) registriert der AppBloc,
|
||||||
// bevor irgendein Bloc starten kann. Wirft eine Compile-Sicherheit
|
// sobald die Umgebung aus `assets/hl_server_config.json` feststeht. Alle
|
||||||
// hin, dass `HolzleitnerApi` ab hier per `locator<HolzleitnerApi>()`
|
// Konsumenten von `locator<HolzleitnerApi>()` entstehen erst danach
|
||||||
// verfügbar ist.
|
// (Bloc-Baum wird erst bei `AppConfigLoaded` gebaut).
|
||||||
registerNetworking(locator: locator);
|
|
||||||
|
|
||||||
// Persistenter Vorschau-Cache für Attachment-Bilder. Über die gesamte
|
// Persistenter Vorschau-Cache für Attachment-Bilder. Über die gesamte
|
||||||
// App-Lebensdauer stabil und zustandslos (das Verzeichnis löst er lazy
|
// App-Lebensdauer stabil und zustandslos (das Verzeichnis löst er lazy
|
||||||
// selbst auf), daher hier als Singleton.
|
// selbst auf), daher hier als Singleton.
|
||||||
locator.registerSingleton<AttachmentCache>(AttachmentCache());
|
locator.registerSingleton<AttachmentCache>(AttachmentCache());
|
||||||
|
|
||||||
runApp(MultiBlocProvider(providers: [
|
runApp(MultiBlocProvider(providers: [
|
||||||
BlocProvider(create: (context) => AppBloc(),),
|
BlocProvider(
|
||||||
|
create: (context) => AppBloc(
|
||||||
|
repository: EnvironmentRepository(),
|
||||||
|
activate: (config) =>
|
||||||
|
registerNetworking(locator: locator, config: config),
|
||||||
|
),
|
||||||
|
),
|
||||||
BlocProvider(create: (context) => SettingsBloc())
|
BlocProvider(create: (context) => SettingsBloc())
|
||||||
], child: App()));
|
], child: App()));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,6 +44,10 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is AppConfigLoaded) {
|
if (state is AppConfigLoaded) {
|
||||||
return MultiBlocProvider(
|
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: [
|
providers: [
|
||||||
BlocProvider(create: (context) => NavigationBloc()),
|
BlocProvider(create: (context) => NavigationBloc()),
|
||||||
BlocProvider(create: (context) => OperationBloc()),
|
BlocProvider(create: (context) => OperationBloc()),
|
||||||
@ -77,7 +81,11 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
|||||||
),
|
),
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) =>
|
create: (context) =>
|
||||||
CarSelectBloc(repository: CarSelectionRepository()),
|
CarSelectBloc(
|
||||||
|
repository: CarSelectionRepository(
|
||||||
|
environmentId: state.active.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
// Phase-D-Migration: produktive CarsRepository-Impl
|
// Phase-D-Migration: produktive CarsRepository-Impl
|
||||||
@ -171,7 +179,12 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
|||||||
title: 'Holzleitner Auslieferung',
|
title: 'Holzleitner Auslieferung',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
home: Scaffold(
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
113
test/environment_settings_page_test.dart
Normal file
113
test/environment_settings_page_test.dart
Normal file
@ -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<ServerCatalog> loadCatalog(String assetPath) async =>
|
||||||
|
ServerCatalog.fromJson(
|
||||||
|
jsonDecode(File(assetPath).readAsStringSync()) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> loadSelectedId() async => selectedId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveSelectedId(String id) async => selectedId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late _FakeRepository repo;
|
||||||
|
late List<BackendConfig> 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
73
test/server_catalog_test.dart
Normal file
73
test/server_catalog_test.dart
Normal file
@ -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<String, dynamic>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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<HolzleitnerApi>();
|
||||||
|
|
||||||
|
registerNetworking(
|
||||||
|
locator: locator,
|
||||||
|
config: catalog.configFor(catalog.byId('test')!),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(locator<BackendConfig>().apiBaseUrl, 'http://192.168.1.9:3001');
|
||||||
|
expect(identical(locator<HolzleitnerApi>(), firstApi), isFalse);
|
||||||
|
expect(locator<HolzleitnerApi>().dio.options.baseUrl,
|
||||||
|
'http://192.168.1.9:3001');
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -14,13 +14,29 @@
|
|||||||
/// 2. /me/cars (mit Auth via DevPasswordGrantTokenProvider)
|
/// 2. /me/cars (mit Auth via DevPasswordGrantTokenProvider)
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
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/dev_password_grant_token_provider.dart';
|
||||||
import 'package:hl_lieferservice/data/network/holzleitner_api_factory.dart';
|
import 'package:hl_lieferservice/data/network/holzleitner_api_factory.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main(List<String> args) async {
|
||||||
const config = BackendConfig.prod;
|
// 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<String, dynamic>,
|
||||||
|
);
|
||||||
|
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,
|
// Health geht ohne Auth — wir nutzen die generierte API-Klasse trotzdem,
|
||||||
// um zu zeigen, dass der Aufruf-Pfad funktioniert.
|
// um zu zeigen, dass der Aufruf-Pfad funktioniert.
|
||||||
|
|||||||
Reference in New Issue
Block a user