From dbf1364542949c8740ba0547f420df0a4cbd00c1 Mon Sep 17 00:00:00 2001 From: Dmytro Serdun Date: Wed, 24 Jun 2026 10:22:53 +0300 Subject: [PATCH 1/4] feat(app): allow a host to inject theme and feature config Add optional ThemeSettings, ThemeMode and FeatureAccess parameters to RootApp. When a host (the theme configurator's realtime preview) mounts the app in-process it supplies them; they are provided down the tree as plain values so the app renders the host's config instead of its bootstrap-built defaults: - FeatureAccess: a host-supplied value replaces the reactive StreamProvider. - ThemeSettings / ThemeMode: provided as nullable values; App pushes a non-null host theme into the AppBloc (AppThemeSettingsChanged / AppThemeModeChanged) in didChangeDependencies, keeping AppState the single source of truth for theme. All parameters are null in a normal standalone run, where behaviour is unchanged. --- lib/app/view/app.dart | 17 +++++++++++++++++ lib/main.dart | 27 +++++++++++++++++++++------ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 766a0a83c..c845e5d80 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -31,6 +31,9 @@ class _AppState extends State { late final AppBloc appBloc; late final AppRouter appRouter; + ThemeSettings? _lastHostThemeSettings; + ThemeMode? _lastHostThemeMode; + @override void initState() { super.initState(); @@ -104,6 +107,20 @@ class _AppState extends State { initialTabResolver, featureAccess.checker, ); + + // A host (the configurator's realtime preview) supplies its theme through the + // tree; push it into the AppBloc so AppState stays the single source of truth. + final hostThemeSettings = context.watch(); + if (hostThemeSettings != null && hostThemeSettings != _lastHostThemeSettings) { + _lastHostThemeSettings = hostThemeSettings; + appBloc.add(AppThemeSettingsChanged(hostThemeSettings)); + } + + final hostThemeMode = context.watch(); + if (hostThemeMode != null && hostThemeMode != _lastHostThemeMode) { + _lastHostThemeMode = hostThemeMode; + appBloc.add(AppThemeModeChanged(hostThemeMode)); + } } @override diff --git a/lib/main.dart b/lib/main.dart index b199fc7e7..f927a8e08 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,7 @@ import 'package:webtrit_phone/environment_config.dart'; import 'package:webtrit_phone/models/models.dart'; import 'package:webtrit_phone/repositories/repositories.dart'; import 'package:webtrit_phone/services/services.dart'; +import 'package:webtrit_phone/theme/theme.dart'; import 'package:webtrit_phone/utils/utils.dart'; void main() { @@ -66,10 +67,17 @@ void _onRootLogRecord(LogRecord record) { } class RootApp extends StatelessWidget { - const RootApp({super.key, required this.instanceRegistry}); + const RootApp({super.key, required this.instanceRegistry, this.themeSettings, this.themeMode, this.featureAccess}); final InstanceRegistry instanceRegistry; + /// Optional config supplied by an external host (the configurator's realtime + /// preview) and provided down the tree so the app renders it instead of its + /// own. Null in a normal run, where the app uses its bootstrap-built defaults. + final ThemeSettings? themeSettings; + final ThemeMode? themeMode; + final FeatureAccess? featureAccess; + @override Widget build(BuildContext context) { return MultiProvider( @@ -85,11 +93,18 @@ class RootApp extends StatelessWidget { // Provides reactive [FeatureAccess] configuration synchronized with [SystemInfoRepository] and [RemoteConfigService]. // // Initializes with bootstrap data and updates whenever system information or remote configuration changes. - StreamProvider( - initialData: instanceRegistry.get(), - create: (_) => instanceRegistry.get().create(), - updateShouldNotify: (previous, next) => previous != next, - ), + // A host (the configurator's realtime preview) overrides it with a plain value it re-supplies on edits. + if (featureAccess != null) + Provider.value(value: featureAccess!) + else + StreamProvider( + initialData: instanceRegistry.get(), + create: (_) => instanceRegistry.get().create(), + updateShouldNotify: (previous, next) => previous != next, + ), + // Optional host-supplied theme; null in a normal run (App falls back to its AppBloc theme). + Provider.value(value: themeSettings), + Provider.value(value: themeMode), Provider(create: (_) => instanceRegistry.get()), Provider(create: (_) => instanceRegistry.get()), Provider(create: (_) => instanceRegistry.get()), From 02b8e0d1919dfe2d724dda2aecc086938251ee17 Mon Sep 17 00:00:00 2001 From: Dmytro Serdun Date: Wed, 24 Jun 2026 10:37:56 +0300 Subject: [PATCH 2/4] fix(app): do not persist a host-injected theme mode The realtime preview pushed the host theme mode through AppThemeModeChanged, whose handler writes it into themeModeRepository (setThemeMode/clear). An ephemeral preview therefore overwrote the device's saved theme-mode preference, so the real app booted in the previewed mode afterwards. Add a non-persisting AppThemeModePreviewed event that only emits the mode into AppState, and dispatch it for host-supplied modes. Theme settings already used a pure emit, so only the mode path needed this. The handler is synchronous and registered without droppable(), so rapid preview toggles are no longer dropped. --- lib/app/view/app.dart | 4 +++- lib/blocs/app/app_bloc.dart | 5 +++++ lib/blocs/app/app_event.dart | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index c845e5d80..78a45920c 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -119,7 +119,9 @@ class _AppState extends State { final hostThemeMode = context.watch(); if (hostThemeMode != null && hostThemeMode != _lastHostThemeMode) { _lastHostThemeMode = hostThemeMode; - appBloc.add(AppThemeModeChanged(hostThemeMode)); + // Previewed, not persisted: the host theme mode is ephemeral and must not + // overwrite the device's saved preference (unlike AppThemeModeChanged). + appBloc.add(AppThemeModePreviewed(hostThemeMode)); } } diff --git a/lib/blocs/app/app_bloc.dart b/lib/blocs/app/app_bloc.dart index 6b95e9870..9e669d3ee 100644 --- a/lib/blocs/app/app_bloc.dart +++ b/lib/blocs/app/app_bloc.dart @@ -57,6 +57,7 @@ class AppBloc extends Bloc { on(_onLoggedIn); on(_onThemeSettingsChanged, transformer: droppable()); on(_onThemeModeChanged, transformer: droppable()); + on(_onThemeModePreviewed); on(_onLocaleChanged, transformer: droppable()); on(_onUserAgreementAccepted, transformer: droppable()); on(_onLogoutRequested, transformer: droppable()); @@ -211,6 +212,10 @@ class AppBloc extends Bloc { emit(state.copyWith(themeMode: themeMode)); } + void _onThemeModePreviewed(AppThemeModePreviewed event, Emitter emit) { + emit(state.copyWith(themeMode: event.value)); + } + void _onLocaleChanged(AppLocaleChanged event, Emitter emit) async { final locale = event.value; if (locale == LocaleExtension.defaultNull) { diff --git a/lib/blocs/app/app_event.dart b/lib/blocs/app/app_event.dart index c49ae85ac..f0a41220f 100644 --- a/lib/blocs/app/app_event.dart +++ b/lib/blocs/app/app_event.dart @@ -63,6 +63,20 @@ class AppThemeModeChanged extends AppEvent { List get props => [value]; } +/// Sets the theme mode for the current session only, without persisting it. +/// +/// Used when an external host (the configurator's realtime preview) supplies an +/// ephemeral theme mode: it must not overwrite the device's saved preference, +/// unlike [AppThemeModeChanged] which is a user action and is persisted. +class AppThemeModePreviewed extends AppEvent { + const AppThemeModePreviewed(this.value); + + final ThemeMode value; + + @override + List get props => [value]; +} + class AppLocaleChanged extends AppEvent { const AppLocaleChanged(this.value); From b6b17dd775cd1932ab282385a6c1a5ce1028c0d4 Mon Sep 17 00:00:00 2001 From: Dmytro Serdun Date: Wed, 24 Jun 2026 10:50:49 +0300 Subject: [PATCH 3/4] Revert "fix(app): do not persist a host-injected theme mode" This reverts commit 02b8e0d1919dfe2d724dda2aecc086938251ee17. --- lib/app/view/app.dart | 4 +--- lib/blocs/app/app_bloc.dart | 5 ----- lib/blocs/app/app_event.dart | 14 -------------- 3 files changed, 1 insertion(+), 22 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 78a45920c..c845e5d80 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -119,9 +119,7 @@ class _AppState extends State { final hostThemeMode = context.watch(); if (hostThemeMode != null && hostThemeMode != _lastHostThemeMode) { _lastHostThemeMode = hostThemeMode; - // Previewed, not persisted: the host theme mode is ephemeral and must not - // overwrite the device's saved preference (unlike AppThemeModeChanged). - appBloc.add(AppThemeModePreviewed(hostThemeMode)); + appBloc.add(AppThemeModeChanged(hostThemeMode)); } } diff --git a/lib/blocs/app/app_bloc.dart b/lib/blocs/app/app_bloc.dart index 9e669d3ee..6b95e9870 100644 --- a/lib/blocs/app/app_bloc.dart +++ b/lib/blocs/app/app_bloc.dart @@ -57,7 +57,6 @@ class AppBloc extends Bloc { on(_onLoggedIn); on(_onThemeSettingsChanged, transformer: droppable()); on(_onThemeModeChanged, transformer: droppable()); - on(_onThemeModePreviewed); on(_onLocaleChanged, transformer: droppable()); on(_onUserAgreementAccepted, transformer: droppable()); on(_onLogoutRequested, transformer: droppable()); @@ -212,10 +211,6 @@ class AppBloc extends Bloc { emit(state.copyWith(themeMode: themeMode)); } - void _onThemeModePreviewed(AppThemeModePreviewed event, Emitter emit) { - emit(state.copyWith(themeMode: event.value)); - } - void _onLocaleChanged(AppLocaleChanged event, Emitter emit) async { final locale = event.value; if (locale == LocaleExtension.defaultNull) { diff --git a/lib/blocs/app/app_event.dart b/lib/blocs/app/app_event.dart index f0a41220f..c49ae85ac 100644 --- a/lib/blocs/app/app_event.dart +++ b/lib/blocs/app/app_event.dart @@ -63,20 +63,6 @@ class AppThemeModeChanged extends AppEvent { List get props => [value]; } -/// Sets the theme mode for the current session only, without persisting it. -/// -/// Used when an external host (the configurator's realtime preview) supplies an -/// ephemeral theme mode: it must not overwrite the device's saved preference, -/// unlike [AppThemeModeChanged] which is a user action and is persisted. -class AppThemeModePreviewed extends AppEvent { - const AppThemeModePreviewed(this.value); - - final ThemeMode value; - - @override - List get props => [value]; -} - class AppLocaleChanged extends AppEvent { const AppLocaleChanged(this.value); From 64fe7b4fdb412ee125d0fda74263336c213dfbc3 Mon Sep 17 00:00:00 2001 From: Dmytro Serdun Date: Wed, 24 Jun 2026 10:52:46 +0300 Subject: [PATCH 4/4] refactor(app): drive host theme mode through featureAccess Drop the separate themeMode injection parameter from RootApp. The app already treats featureAccess.supportedConfig.themeMode as the authoritative theme mode (build() derives finalThemeMode from it), and a host always supplies featureAccess, so a parallel themeMode path was redundant. It was also the only host-config path that persisted: it dispatched AppThemeModeChanged, whose handler writes to themeModeRepository. The app runs either standalone (config from local storage) or embedded in a host that provides the config - never both - so this never corrupted a real preference, but routing the mode through featureAccess keeps a single, non-persisting source for it. Host theme settings still flow through AppThemeSettingsChanged (a pure emit). --- lib/app/view/app.dart | 9 ++------- lib/main.dart | 8 +++++--- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index c845e5d80..9fff9032a 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -32,7 +32,6 @@ class _AppState extends State { late final AppRouter appRouter; ThemeSettings? _lastHostThemeSettings; - ThemeMode? _lastHostThemeMode; @override void initState() { @@ -110,17 +109,13 @@ class _AppState extends State { // A host (the configurator's realtime preview) supplies its theme through the // tree; push it into the AppBloc so AppState stays the single source of truth. + // The theme mode is not pushed here: it comes from featureAccess + // (supportedConfig.themeMode), which build() already treats as authoritative. final hostThemeSettings = context.watch(); if (hostThemeSettings != null && hostThemeSettings != _lastHostThemeSettings) { _lastHostThemeSettings = hostThemeSettings; appBloc.add(AppThemeSettingsChanged(hostThemeSettings)); } - - final hostThemeMode = context.watch(); - if (hostThemeMode != null && hostThemeMode != _lastHostThemeMode) { - _lastHostThemeMode = hostThemeMode; - appBloc.add(AppThemeModeChanged(hostThemeMode)); - } } @override diff --git a/lib/main.dart b/lib/main.dart index f927a8e08..de15527b3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -67,15 +67,18 @@ void _onRootLogRecord(LogRecord record) { } class RootApp extends StatelessWidget { - const RootApp({super.key, required this.instanceRegistry, this.themeSettings, this.themeMode, this.featureAccess}); + const RootApp({super.key, required this.instanceRegistry, this.themeSettings, this.featureAccess}); final InstanceRegistry instanceRegistry; /// Optional config supplied by an external host (the configurator's realtime /// preview) and provided down the tree so the app renders it instead of its /// own. Null in a normal run, where the app uses its bootstrap-built defaults. + /// + /// The theme mode is intentionally not a separate parameter: a host drives it + /// through [featureAccess] (`supportedConfig.themeMode`), which the app already + /// treats as the authoritative mode, so there is a single source for it. final ThemeSettings? themeSettings; - final ThemeMode? themeMode; final FeatureAccess? featureAccess; @override @@ -104,7 +107,6 @@ class RootApp extends StatelessWidget { ), // Optional host-supplied theme; null in a normal run (App falls back to its AppBloc theme). Provider.value(value: themeSettings), - Provider.value(value: themeMode), Provider(create: (_) => instanceRegistry.get()), Provider(create: (_) => instanceRegistry.get()), Provider(create: (_) => instanceRegistry.get()),