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,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"
}
]
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

@ -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) {

View File

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

View File

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

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

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

View File

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