Skip to content

Commit c227232

Browse files
authored
Merge pull request #120 from flutter-news-app-full-source-code/Resolving-Remote-Config-Race-Condition-on-App-Launch
Resolving remote config race condition on app launch
2 parents 6390016 + 9f97997 commit c227232

File tree

13 files changed

+456
-287
lines changed

13 files changed

+456
-287
lines changed

lib/account/view/account_page.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class AccountPage extends StatelessWidget {
2323
final appState = context.watch<AppBloc>().state;
2424
final user = appState.user;
2525
final status = appState.status;
26-
final isAnonymous = status == AppStatus.anonymous;
26+
final isAnonymous = status == AppLifeCycleStatus.anonymous;
2727
final theme = Theme.of(context);
2828
final textTheme = theme.textTheme;
2929

lib/app/bloc/app_bloc.dart

Lines changed: 320 additions & 187 deletions
Large diffs are not rendered by default.

lib/app/bloc/app_state.dart

Lines changed: 88 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,137 @@
11
part of 'app_bloc.dart';
22

3-
/// Represents the application's authentication status.
4-
enum AppStatus {
5-
/// The application is initializing and the status is unknown.
6-
initial,
3+
/// Defines the various statuses of the application's overall state.
4+
///
5+
/// This enum helps manage the application's flow, especially during startup
6+
/// and critical operations like fetching remote configuration or handling
7+
/// authentication changes.
8+
enum AppLifeCycleStatus {
9+
/// The application is in the initial phase of bootstrapping,
10+
/// fetching remote configuration and user settings.
11+
initializing,
12+
13+
/// The user is not authenticated.
14+
unauthenticated,
715

8-
/// The user is authenticated.
16+
/// The user is authenticated (e.g., standard user).
917
authenticated,
1018

11-
/// The user is unauthenticated.
12-
unauthenticated,
13-
14-
/// The user is anonymous (signed in using an anonymous provider).
19+
/// The user is anonymous (e.g., guest user).
1520
anonymous,
1621

17-
/// Fetching the essential RemoteConfig.
22+
/// The application is currently fetching remote configuration.
23+
/// This status is used for re-fetching or background checks, not initial load.
1824
configFetching,
1925

20-
/// Fetching the essential RemoteConfig failed.
26+
/// The application failed to fetch remote configuration.
2127
configFetchFailed,
2228

23-
/// A new version of the app is required.
24-
updateRequired,
25-
26-
/// The app is currently under maintenance.
29+
/// The application is currently under maintenance.
2730
underMaintenance,
31+
32+
/// A mandatory update is required for the application.
33+
updateRequired,
2834
}
2935

36+
/// {@template app_state}
37+
/// Represents the overall state of the application.
38+
///
39+
/// This state includes authentication status, user settings, remote
40+
/// configuration, and UI-related preferences.
41+
/// {@endtemplate}
3042
class AppState extends Equatable {
3143
/// {@macro app_state}
3244
const AppState({
45+
required this.status,
3346
required this.settings,
34-
required this.selectedBottomNavigationIndex,
35-
this.themeMode = ThemeMode.system,
36-
this.appTextScaleFactor = AppTextScaleFactor.medium,
37-
this.flexScheme = FlexScheme.material,
38-
this.fontFamily,
39-
this.status = AppStatus.initial, // Changed from AppStatus
47+
required this.environment,
4048
this.user,
41-
this.locale = const Locale('en'), // Default to English
4249
this.remoteConfig,
43-
this.environment,
50+
this.themeMode = ThemeMode.system,
51+
this.flexScheme = FlexScheme.blue,
52+
this.fontFamily,
53+
this.appTextScaleFactor = AppTextScaleFactor.medium,
54+
this.selectedBottomNavigationIndex = 0,
55+
this.locale,
4456
});
4557

46-
/// The index of the currently selected item in the bottom navigation bar.
47-
final int selectedBottomNavigationIndex;
58+
/// The current status of the application.
59+
final AppLifeCycleStatus status;
4860

49-
/// The overall theme mode (light, dark, system).
50-
final ThemeMode themeMode;
61+
/// The currently authenticated or anonymous user.
62+
final User? user;
5163

52-
/// The text scale factor for the app's UI.
53-
final AppTextScaleFactor appTextScaleFactor;
64+
/// The user's application settings, including display preferences.
65+
final UserAppSettings settings;
5466

55-
/// The active color scheme defined by FlexColorScheme.
67+
/// The remote configuration fetched from the backend.
68+
final RemoteConfig? remoteConfig;
69+
70+
/// The current theme mode (light, dark, or system).
71+
final ThemeMode themeMode;
72+
73+
/// The current FlexColorScheme scheme for accent colors.
5674
final FlexScheme flexScheme;
5775

58-
/// The active font family name (e.g., from Google Fonts).
59-
/// Null uses the default font family defined in the FlexColorScheme theme.
76+
/// The currently selected font family.
6077
final String? fontFamily;
6178

62-
/// The current authentication status of the application.
63-
final AppStatus status;
64-
65-
/// The current user details. Null if unauthenticated.
66-
final User? user;
79+
/// The current text scale factor.
80+
final AppTextScaleFactor appTextScaleFactor;
6781

68-
/// User-specific application settings.
69-
final UserAppSettings settings;
82+
/// The currently selected index for bottom navigation.
83+
final int selectedBottomNavigationIndex;
7084

71-
/// The current application locale.
72-
final Locale locale;
85+
/// The current application environment.
86+
final local_config.AppEnvironment environment;
7387

74-
/// The global application configuration (remote config).
75-
final RemoteConfig? remoteConfig;
88+
/// The currently selected locale for localization.
89+
final Locale? locale;
7690

77-
/// The current application environment (e.g., production, development, demo).
78-
final local_config.AppEnvironment? environment;
91+
@override
92+
List<Object?> get props => [
93+
status,
94+
user,
95+
settings,
96+
remoteConfig,
97+
themeMode,
98+
flexScheme,
99+
fontFamily,
100+
appTextScaleFactor,
101+
selectedBottomNavigationIndex,
102+
environment,
103+
locale,
104+
];
79105

80-
/// Creates a copy of the current state with updated values.
106+
/// Creates a copy of this [AppState] with the given fields replaced with
107+
/// the new values.
81108
AppState copyWith({
82-
int? selectedBottomNavigationIndex,
109+
AppLifeCycleStatus? status,
110+
User? user,
111+
UserAppSettings? settings,
112+
RemoteConfig? remoteConfig,
113+
bool clearAppConfig = false,
83114
ThemeMode? themeMode,
84115
FlexScheme? flexScheme,
85116
String? fontFamily,
86117
AppTextScaleFactor? appTextScaleFactor,
87-
AppStatus? status, // Changed from AppStatus
88-
User? user,
89-
UserAppSettings? settings,
90-
Locale? locale,
91-
RemoteConfig? remoteConfig,
118+
int? selectedBottomNavigationIndex,
92119
local_config.AppEnvironment? environment,
93-
bool clearFontFamily = false,
94-
bool clearAppConfig = false,
95-
bool clearEnvironment = false,
120+
Locale? locale,
96121
}) {
97122
return AppState(
98-
selectedBottomNavigationIndex:
99-
selectedBottomNavigationIndex ?? this.selectedBottomNavigationIndex,
100-
themeMode: themeMode ?? this.themeMode,
101-
flexScheme: flexScheme ?? this.flexScheme,
102-
fontFamily: clearFontFamily ? null : fontFamily ?? this.fontFamily,
103-
appTextScaleFactor: appTextScaleFactor ?? this.appTextScaleFactor,
104123
status: status ?? this.status,
105124
user: user ?? this.user,
106125
settings: settings ?? this.settings,
107-
locale: locale ?? this.locale,
108126
remoteConfig: clearAppConfig ? null : remoteConfig ?? this.remoteConfig,
109-
environment: clearEnvironment ? null : environment ?? this.environment,
127+
themeMode: themeMode ?? this.themeMode,
128+
flexScheme: flexScheme ?? this.flexScheme,
129+
fontFamily: fontFamily ?? this.fontFamily,
130+
appTextScaleFactor: appTextScaleFactor ?? this.appTextScaleFactor,
131+
selectedBottomNavigationIndex:
132+
selectedBottomNavigationIndex ?? this.selectedBottomNavigationIndex,
133+
environment: environment ?? this.environment,
134+
locale: locale ?? this.locale,
110135
);
111136
}
112-
113-
@override
114-
List<Object?> get props => [
115-
selectedBottomNavigationIndex,
116-
themeMode,
117-
flexScheme,
118-
fontFamily,
119-
appTextScaleFactor,
120-
status,
121-
user,
122-
settings,
123-
locale,
124-
remoteConfig,
125-
environment,
126-
];
127137
}

lib/app/services/app_status_service.dart

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
44
import 'package:flutter_bloc/flutter_bloc.dart';
55
import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart';
66
import 'package:flutter_news_app_mobile_client_full_source_code/app/config/config.dart';
7+
import 'package:logging/logging.dart';
78

89
/// {@template app_status_service}
910
/// A service dedicated to monitoring the application's lifecycle and
@@ -32,7 +33,8 @@ class AppStatusService with WidgetsBindingObserver {
3233
required AppEnvironment environment,
3334
}) : _context = context,
3435
_checkInterval = checkInterval,
35-
_environment = environment {
36+
_environment = environment,
37+
_logger = Logger('AppStatusService') {
3638
// Immediately register this service as a lifecycle observer.
3739
WidgetsBinding.instance.addObserver(this);
3840
// Start the periodic checks.
@@ -48,6 +50,9 @@ class AppStatusService with WidgetsBindingObserver {
4850
/// The current application environment.
4951
final AppEnvironment _environment;
5052

53+
/// The logger instance for this service.
54+
final Logger _logger;
55+
5156
/// The timer responsible for periodic checks.
5257
Timer? _timer;
5358

@@ -62,10 +67,10 @@ class AppStatusService with WidgetsBindingObserver {
6267
_timer = Timer.periodic(_checkInterval, (_) {
6368
// In demo mode, periodic checks are not needed as there's no backend.
6469
if (_environment == AppEnvironment.demo) {
65-
print('[AppStatusService] Demo mode: Skipping periodic check.');
70+
_logger.info('[AppStatusService] Demo mode: Skipping periodic check.');
6671
return;
6772
}
68-
print(
73+
_logger.info(
6974
'[AppStatusService] Periodic check triggered. Requesting AppConfig fetch.',
7075
);
7176
// Add the event to the AppBloc to fetch the latest config.
@@ -84,13 +89,13 @@ class AppStatusService with WidgetsBindingObserver {
8489
// useful on web, where switching browser tabs would otherwise trigger
8590
// a reload, which is unnecessary and can be distracting for demos.
8691
if (_environment == AppEnvironment.demo) {
87-
print('[AppStatusService] Demo mode: Skipping app lifecycle check.');
92+
_logger.info('[AppStatusService] Demo mode: Skipping app lifecycle check.');
8893
return;
8994
}
9095

9196
// We are only interested in the 'resumed' state.
9297
if (state == AppLifecycleState.resumed) {
93-
print('[AppStatusService] App resumed. Requesting AppConfig fetch.');
98+
_logger.info('[AppStatusService] App resumed. Requesting AppConfig fetch.');
9499
// When the app comes to the foreground, immediately trigger a check.
95100
// This is crucial for catching maintenance mode that was enabled
96101
// while the app was in the background.
@@ -106,7 +111,7 @@ class AppStatusService with WidgetsBindingObserver {
106111
/// the main app widget is disposed) to prevent memory leaks from the
107112
/// timer and the observer registration.
108113
void dispose() {
109-
print('[AppStatusService] Disposing service.');
114+
_logger.info('[AppStatusService] Disposing service.');
110115
// Stop the periodic timer.
111116
_timer?.cancel();
112117
// Remove this object from the list of lifecycle observers.

lib/app/view/app.dart

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -188,15 +188,15 @@ class _AppView extends StatefulWidget {
188188

189189
class _AppViewState extends State<_AppView> {
190190
late final GoRouter _router;
191-
late final ValueNotifier<AppStatus> _statusNotifier;
191+
late final ValueNotifier<AppLifeCycleStatus> _statusNotifier;
192192
AppStatusService? _appStatusService;
193193

194194
@override
195195
void initState() {
196196
super.initState();
197197
final appBloc = context.read<AppBloc>();
198198
// Initialize the notifier with the BLoC's current state
199-
_statusNotifier = ValueNotifier<AppStatus>(appBloc.state.status);
199+
_statusNotifier = ValueNotifier<AppLifeCycleStatus>(appBloc.state.status);
200200

201201
// Instantiate and initialize the AppStatusService.
202202
// This service will automatically trigger checks when the app is resumed
@@ -259,7 +259,7 @@ class _AppViewState extends State<_AppView> {
259259
// By returning a dedicated widget here, we ensure these pages are
260260
// full-screen and exist outside the main app's navigation shell.
261261

262-
if (state.status == AppStatus.underMaintenance) {
262+
if (state.status == AppLifeCycleStatus.underMaintenance) {
263263
// The app is in maintenance mode. Show the MaintenancePage.
264264
//
265265
// WHY A SEPARATE MATERIALAPP?
@@ -299,7 +299,7 @@ class _AppViewState extends State<_AppView> {
299299
);
300300
}
301301

302-
if (state.status == AppStatus.updateRequired) {
302+
if (state.status == AppLifeCycleStatus.updateRequired) {
303303
// A mandatory update is required. Show the UpdateRequiredPage.
304304
return MaterialApp(
305305
debugShowCheckedModeBanner: false,
@@ -326,8 +326,8 @@ class _AppViewState extends State<_AppView> {
326326
);
327327
}
328328

329-
if (state.status == AppStatus.configFetching ||
330-
state.status == AppStatus.configFetchFailed) {
329+
if (state.status == AppLifeCycleStatus.configFetching ||
330+
state.status == AppLifeCycleStatus.configFetchFailed) {
331331
// The app is in the process of fetching its initial remote
332332
// configuration or has failed to do so. The StatusPage handles
333333
// both the loading indicator and the retry mechanism.

lib/bloc_observer.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ class AppBlocObserver extends BlocObserver {
88
@override
99
void onChange(BlocBase<dynamic> bloc, Change<dynamic> change) {
1010
super.onChange(bloc, change);
11-
log('onChange(${bloc.runtimeType}, $change)');
11+
log(
12+
'onChange(${bloc.runtimeType}, $change.currentState -> $change.nextState)',
13+
);
1214
}
1315

1416
@override

lib/l10n/app_localizations.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1681,6 +1681,12 @@ abstract class AppLocalizations {
16811681
/// In en, this message translates to:
16821682
/// **'Ads help keep this app free !'**
16831683
String get adInfoPlaceholderText;
1684+
1685+
/// Text for a button that allows the user to retry a failed operation.
1686+
///
1687+
/// In en, this message translates to:
1688+
/// **'Retry'**
1689+
String get retryButtonText;
16841690
}
16851691

16861692
class _AppLocalizationsDelegate

lib/l10n/app_localizations_ar.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -877,4 +877,7 @@ class AppLocalizationsAr extends AppLocalizations {
877877
@override
878878
String get adInfoPlaceholderText =>
879879
'الإعلانات تساعد في إبقاء هذا التطبيق مجانيًا.';
880+
881+
@override
882+
String get retryButtonText => 'إعادة المحاولة';
880883
}

lib/l10n/app_localizations_en.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,4 +879,7 @@ class AppLocalizationsEn extends AppLocalizations {
879879

880880
@override
881881
String get adInfoPlaceholderText => 'Ads help keep this app free !';
882+
883+
@override
884+
String get retryButtonText => 'Retry';
882885
}

lib/l10n/arb/app_ar.arb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,5 +1144,9 @@
11441144
"adInfoPlaceholderText": "الإعلانات تساعد في إبقاء هذا التطبيق مجانيًا.",
11451145
"@adInfoPlaceholderText": {
11461146
"description": "رسالة معروضة في خانات الإعلانات عند تحميل الإعلانات أو فشلها، تشرح الغرض منها."
1147+
},
1148+
"retryButtonText": "إعادة المحاولة",
1149+
"@retryButtonText": {
1150+
"description": "Text for a button that allows the user to retry a failed operation."
11471151
}
11481152
}

0 commit comments

Comments
 (0)