Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions lib/app/view/app.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:flutter/material.dart';

import 'package:flutter_bloc/flutter_bloc.dart';
Expand All @@ -21,7 +23,23 @@ import 'package:webtrit_phone/resolvers/resolvers.dart';
final _logger = Logger('AppWidget');

class App extends StatefulWidget {
const App({super.key});
const App({super.key, this.externalThemeSettings, this.externalThemeMode, this.embeddedPreview = false});

/// Optional external [ThemeSettings] stream used by embedders such as the
/// theme configurator's realtime preview to drive the running app's
/// appearance live. Emissions are applied via [AppThemeSettingsChanged] and
/// are not persisted to local preferences.
final Stream<ThemeSettings>? externalThemeSettings;

/// Optional external [ThemeMode] stream paired with [externalThemeSettings];
/// emissions are applied via [AppThemeModeChanged].
final Stream<ThemeMode>? externalThemeMode;

/// Whether the app runs embedded inside another Flutter host (realtime
/// preview). When true, Firebase-backed integrations such as the analytics
/// navigator observer are skipped to avoid touching a misconfigured/absent
/// Firebase app.
final bool embeddedPreview;

@override
State<App> createState() => _AppState();
Expand All @@ -31,6 +49,9 @@ class _AppState extends State<App> {
late final AppBloc appBloc;
late final AppRouter appRouter;

StreamSubscription<ThemeSettings>? _externalThemeSettingsSubscription;
StreamSubscription<ThemeMode>? _externalThemeModeSubscription;

@override
void initState() {
super.initState();
Expand Down Expand Up @@ -83,6 +104,13 @@ class _AppState extends State<App> {
initialTabResolver,
featureAccess.checker,
);

_externalThemeSettingsSubscription = widget.externalThemeSettings?.listen(
(settings) => appBloc.add(AppThemeSettingsChanged(settings)),
);
_externalThemeModeSubscription = widget.externalThemeMode?.listen(
(themeMode) => appBloc.add(AppThemeModeChanged(themeMode)),
);
}

@override
Expand All @@ -108,6 +136,8 @@ class _AppState extends State<App> {

@override
void dispose() {
_externalThemeSettingsSubscription?.cancel();
_externalThemeModeSubscription?.cancel();
appBloc.close();
super.dispose();
}
Expand Down Expand Up @@ -147,7 +177,7 @@ class _AppState extends State<App> {
deepLinkBuilder: isDeepLinkEnabled ? appRouter.deepLinkBuilder : null,
navigatorObservers: () => [
AppRouterObserver(),
context.read<AppAnalyticsRepository>().createObserver(),
if (!widget.embeddedPreview) context.read<AppAnalyticsRepository>().createObserver(),
AutoRouteObserver(),
],
reevaluateListenable: ReevaluateListenable.stream(
Expand Down
39 changes: 33 additions & 6 deletions lib/bootstrap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,32 @@ import 'services/services.dart';
// Dart isolates do not share memory -- each background isolate gets its own instance.
IsolateContext? _isolateContext;

Future<InstanceRegistry> bootstrap() async {
/// Boots the application dependencies.
///
/// When [embeddedPreview] is true the app is hosted inside another Flutter app
/// (e.g. the theme configurator's realtime preview) that already owns the
/// default Firebase app — which may be misconfigured for this app or absent.
/// In that mode all Firebase side effects are skipped and Firebase-backed
/// services fall back to local, offline-safe implementations so the embedded
/// app can render without a valid Firebase configuration.
Future<InstanceRegistry> bootstrap({bool embeddedPreview = false}) async {
final registry = InstanceRegistry();

// External SDKs (Side effects only, don't need registration)
await _initFirebaseApp();
await _initFirebaseMessaging();
await _initLocalPushs();
if (!embeddedPreview) {
await _initFirebaseApp();
await _initFirebaseMessaging();
await _initLocalPushs();
}

// Initialize Components

// App Info & Device Data

final packageInfo = await PackageInfoFactory.init();
final appInfo = await AppInfo.init(FirebaseAppIdProvider());
final appInfo = await AppInfo.init(
embeddedPreview ? const SharedPreferencesAppIdProvider() : FirebaseAppIdProvider(),
);
final deviceInfo = await DeviceInfoFactory.init();

// Storages
Expand Down Expand Up @@ -99,7 +111,22 @@ Future<InstanceRegistry> bootstrap() async {

// Remote configuration
final remoteCacheConfigService = await DefaultRemoteCacheConfigService.init();
final cachedRemoteConfigService = await CachedRemoteConfigService.init(remoteCacheConfigService);
// Firebase Remote Config (and its underlying Installations) may be unavailable
// when the app is embedded in another host (e.g. the theme configurator's
// realtime preview) that owns the default Firebase app, or when offline. Fall
// back to the local shared-preferences cache so bootstrap still completes;
// [DefaultRemoteCacheConfigService] also implements [RemoteConfigService].
RemoteConfigService cachedRemoteConfigService;
if (embeddedPreview) {
cachedRemoteConfigService = remoteCacheConfigService;
} else {
try {
cachedRemoteConfigService = await CachedRemoteConfigService.init(remoteCacheConfigService);
} catch (e, s) {
Logger('bootstrap').warning('Firebase Remote Config init failed; using local cache fallback', e, s);
cachedRemoteConfigService = remoteCacheConfigService;
}
}

final featureAccessStreamFactory = FeatureAccessStreamFactory(
appThemes: appThemes,
Expand Down
51 changes: 46 additions & 5 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -66,10 +67,41 @@ void _onRootLogRecord(LogRecord record) {
}

class RootApp extends StatelessWidget {
const RootApp({super.key, required this.instanceRegistry});
const RootApp({
super.key,
required this.instanceRegistry,
this.externalThemeSettings,
this.externalThemeMode,
this.externalFeatureAccess,
this.externalFeatureAccessInitial,
this.embeddedPreview = false,
});

final InstanceRegistry instanceRegistry;

/// Whether the app runs embedded inside another Flutter host (e.g. the theme
/// configurator's realtime preview). Forwarded to [App] so Firebase-backed
/// integrations such as the analytics navigator observer are skipped.
final bool embeddedPreview;

/// Optional external [ThemeSettings] stream forwarded to [App] so embedders
/// such as the theme configurator's realtime preview can drive the running
/// app's appearance live.
final Stream<ThemeSettings>? externalThemeSettings;

/// Optional external [ThemeMode] stream forwarded to [App], paired with
/// [externalThemeSettings].
final Stream<ThemeMode>? externalThemeMode;

/// Optional external [FeatureAccess] stream that replaces the bootstrap-built
/// reactive configuration, letting embedders (the configurator's realtime
/// preview) drive the live app/login/feature config being edited.
final Stream<FeatureAccess>? externalFeatureAccess;

/// Initial [FeatureAccess] paired with [externalFeatureAccess]; used as the
/// `StreamProvider` seed so the first frame already reflects the edited config.
final FeatureAccess? externalFeatureAccessInitial;

@override
Widget build(BuildContext context) {
return MultiProvider(
Expand All @@ -86,8 +118,8 @@ class RootApp extends StatelessWidget {
//
// Initializes with bootstrap data and updates whenever system information or remote configuration changes.
StreamProvider<FeatureAccess>(
initialData: instanceRegistry.get<FeatureAccess>(),
create: (_) => instanceRegistry.get<FeatureAccessStreamFactory>().create(),
initialData: externalFeatureAccessInitial ?? instanceRegistry.get<FeatureAccess>(),
create: (_) => externalFeatureAccess ?? instanceRegistry.get<FeatureAccessStreamFactory>().create(),
updateShouldNotify: (previous, next) => previous != next,
),
Provider<SecureStorage>(create: (_) => instanceRegistry.get()),
Expand Down Expand Up @@ -147,7 +179,12 @@ class RootApp extends StatelessWidget {
create: (_) => instanceRegistry.get(),
dispose: disposeIfDisposable,
),
RepositoryProvider.value(value: AppAnalyticsRepository(instance: FirebaseAnalytics.instance)),
// Lazy so embedders that skip Firebase (e.g. the configurator's realtime
// preview) never touch FirebaseAnalytics.instance, which throws when the
// host's default Firebase app is misconfigured/absent.
RepositoryProvider<AppAnalyticsRepository>(
create: (_) => AppAnalyticsRepository(instance: FirebaseAnalytics.instance),
),
RepositoryProvider<RegisterStatusRepository>.value(value: registerStatusRepository),
RepositoryProvider<PresenceSettingsRepository>.value(value: presenceSettingsRepository),
RepositoryProvider<QueuedTerminationRequestsRepository>.value(value: queuedTerminationRequestsRepository),
Expand Down Expand Up @@ -176,7 +213,11 @@ class RootApp extends StatelessWidget {
RepositoryProvider<UserLocalDatasource>(create: (_) => instanceRegistry.get()),
RepositoryProvider<AuthRepository>(create: (_) => instanceRegistry.get()),
],
child: const App(),
child: App(
externalThemeSettings: externalThemeSettings,
externalThemeMode: externalThemeMode,
embeddedPreview: embeddedPreview,
),
);
},
),
Expand Down
Loading