Files
Holzleitner-Lieferservice-App/lib/widget/app.dart
Dennis Nemec 200992262a feat(flags): Feature-Flags aus Config-Datei; Gutschriften-Abschnitt abschaltbar
- Schalter stehen in assets/feature_flags.json (Key -> enabled +
  Beschreibung) statt als Konstanten im Code
- Enum Feature buendelt die stabilen Keys; FeatureFlags liest die Datei,
  ignoriert unbekannte Keys und nutzt fuer fehlende den Default aus dem Enum
- Laden beim App-Start im AppBloc, bereitgestellt per RepositoryProvider;
  FeatureGate blendet Bereiche bei inaktivem Feature komplett aus
- articles.credit_section = false: Abschnitt "Gutschriften" im
  Artikel-Step ist ausgeblendet
- Bisheriges Stepper-Flag migriert (articles.credit_amount_stepper); die
  fuenf ungenutzten statischen Flags mit veralteten Kommentaren entfernt
- Tests: Asset enthaelt alle Keys, Parsing, Defaults, FeatureGate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:07:40 +02:00

207 lines
9.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/bloc/app_bloc.dart';
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/presentation/login_enforcer.dart';
import 'package:hl_lieferservice/main.dart' show locator;
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/presentation/car_selection_enforcer.dart';
import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart';
import 'package:hl_lieferservice/data/repository/cars_repository_impl.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart';
import 'package:hl_lieferservice/data/repository/payment_methods_repository_impl.dart';
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi;
import 'package:hl_lieferservice/data/repository/tour_repository_impl.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/feature_flags/feature_flags.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/presentation/operation_view_enforcer.dart';
import 'package:hl_lieferservice/bloc/app_states.dart';
import 'home/presentation/home.dart';
class DeliveryApp extends StatefulWidget {
const DeliveryApp({super.key});
@override
State<StatefulWidget> createState() => _DeliveryAppState();
}
class _DeliveryAppState extends State<DeliveryApp> {
@override
Widget build(BuildContext context) {
return BlocBuilder<AppBloc, AppState>(
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: [
// Feature-Schalter aus `assets/feature_flags.json`; Widgets lesen
// sie über `FeatureGate` bzw. `context.read<FeatureFlags>()`.
RepositoryProvider<FeatureFlags>.value(value: state.featureFlags),
BlocProvider(create: (context) => NavigationBloc()),
BlocProvider(create: (context) => OperationBloc()),
BlocProvider(
create:
(context) => AuthBloc(
tokenProvider:
locator<KeycloakOidcTokenProvider>(),
operationBloc: context.read<OperationBloc>(),
)
// Beim ersten Build: prüfen, ob ein
// Refresh-Token aus der Secure Storage da ist,
// und ggf. direkt einloggen.
..add(const RestoreSessionRequested()),
),
BlocProvider(
// Phase-C+D-2-Migration: produktive TourRepository-Impl
// gegen das generierte Rust-Backend-API. Account-Filter
// serverseitig aus dem JWT, deshalb braucht der Bloc
// keinen AuthBloc-Bezug mehr.
create: (context) => TourBloc(
tourRepository: TourRepositoryImpl(locator<HolzleitnerApi>()),
opBloc: context.read<OperationBloc>(),
attachmentCache: locator<AttachmentCache>(),
),
),
BlocProvider(
// Hält das im Header gewählte Tour-Datum (null = heute).
// Steuert, welchen Tag der TourBloc lädt.
create: (context) => TourDateCubit(),
),
BlocProvider(
create: (context) =>
CarSelectBloc(
repository: CarSelectionRepository(
environmentId: state.active.id,
),
),
),
BlocProvider(
// Phase-D-Migration: produktive CarsRepository-Impl
// gegen das generierte Rust-Backend-API. Account-Filter
// serverseitig aus dem JWT, deshalb braucht der Bloc
// keinen AuthBloc-Bezug mehr.
create: (context) => CarsBloc(
repository: CarsRepositoryImpl(locator<HolzleitnerApi>()),
opBloc: context.read<OperationBloc>(),
),
),
BlocProvider(
// PhaseBloc liest die Team-Fahrzeug-Anzahl jetzt direkt
// aus dem CarsBloc — der ist die alleinige Quelle der
// Fahrzeug-Stammdaten. Beim ersten Load eines Fahrzeugs
// bestimmt das die Eintrittsphase (Auswählen vs. Sortieren).
create: (context) => PhaseBloc(
carCountResolver: () {
final carsState = context.read<CarsBloc>().state;
return carsState is CarsLoaded
? carsState.cars.length
: null;
},
// Bindet die persistierten Phasen-Häkchen an die aktuelle
// Tour-Version (Tour.syncedAt). Ein erneuter Sync/Seed
// schreibt eine neue syncedAt → neuer Token → frische
// Phasen, ohne dass alte lokale Häkchen hängen bleiben.
// Ableitung zentral in `TourStatePhaseToken` (tour_state.dart),
// dieselbe Quelle wie `Home._ensurePhaseLoaded`.
tourTokenResolver: () =>
context.read<TourBloc>().state.phaseToken,
),
),
BlocProvider(
// Zahlungsmethoden sind firmenweite Stammdaten — wir laden
// sie einmal beim App-Start und cachen sie im Cubit. Der
// Detail-Screen einer Lieferung greift darauf zu, um den
// `paymentMethodId`-FK auf einen lesbaren Namen aufzulösen.
create: (context) => PaymentMethodsCubit(
repository:
PaymentMethodsRepositoryImpl(locator<HolzleitnerApi>()),
)..load(),
),
],
child: MaterialApp(
title: 'Holzleitner Auslieferung',
debugShowCheckedModeBanner: false,
// Wrap the Navigator (not just the home route) so the loading
// overlay covers every pushed route — DeliveryDetail, Cars,
// dialogs, etc. — not only the initial home tree.
builder: (context, child) =>
OperationViewEnforcer(child: child ?? const SizedBox.shrink()),
home: BlocBuilder<AppBloc, AppState>(
builder: (context, state) {
if (state is AppConfigLoading) {
return Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (state is AppConfigLoadingFailed) {
return Scaffold(body: Center(child: Text(state.message)));
}
if (state is AppConfigLoaded) {
// Stammdaten nach JEDEM erfolgreichen (Re-)Login neu laden.
// Der Cubit lädt zwar beim App-Start — aber ohne Session
// endet das in 401 („Sitzung abgelaufen") und blieb bisher
// für immer im Fehlerzustand hängen, auch nach dem Login.
return BlocListener<AuthBloc, AuthState>(
listenWhen: (prev, curr) =>
curr is Authenticated && prev is! Authenticated,
listener: (context, _) =>
context.read<PaymentMethodsCubit>().load(),
child: LoginEnforcer(
child: CarSelectionEnforcer(child: Home()),
),
);
}
return Container();
},
),
routes: {"/cars": (context) => CarManagementPage()},
),
);
}
if (state is AppConfigLoadingFailed) {
return MaterialApp(
title: 'Holzleitner Auslieferung',
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(state.message, textAlign: TextAlign.center),
),
),
),
);
}
return MaterialApp(
title: 'Holzleitner Auslieferung',
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(child: const CircularProgressIndicator()),
),
);
},
);
}
}