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: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<AppEvents, AppState> {
|
||||
AppBloc() : super(const AppInitial()) {
|
||||
on<AppLoadConfig>((event, emit) {
|
||||
emit(const AppConfigLoading());
|
||||
emit(const AppConfigLoaded());
|
||||
});
|
||||
AppBloc({
|
||||
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());
|
||||
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 {}
|
||||
|
||||
/// 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});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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<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(
|
||||
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<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
|
||||
/// 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<CarSelection?> getSelection(String userId) async {
|
||||
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: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<SettingsPage> {
|
||||
/// Externer Browser statt In-App-WebView, damit ggf. im Browser
|
||||
/// gespeicherte Credentials / Authenticator-Apps weiter funktionieren.
|
||||
Future<void> _openAccountConsole() async {
|
||||
final issuer = BackendConfig.fromEnvironment.keycloakIssuerUrl;
|
||||
final issuer = locator<BackendConfig>().keycloakIssuerUrl;
|
||||
final uri = Uri.parse('$issuer/account/');
|
||||
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
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_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<HolzleitnerApi>()`
|
||||
// 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<HolzleitnerApi>()` 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>(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()));
|
||||
}
|
||||
|
||||
@ -44,6 +44,10 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
||||
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<DeliveryApp> {
|
||||
),
|
||||
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<DeliveryApp> {
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
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<void> main() async {
|
||||
const config = BackendConfig.prod;
|
||||
Future<void> main(List<String> 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<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,
|
||||
// um zu zeigen, dass der Aufruf-Pfad funktioniert.
|
||||
|
||||
Reference in New Issue
Block a user