Skip to content

Fix app status architecture #73

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Aug 7, 2025
Merged
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ Offer users control over their app experience:
* **Feed Display:** Customize how headlines are presented.
> **Your Advantage:** Deliver a premium, adaptable user experience that caters to individual needs without writing any code. 🔧

#### 📡 **Backend-Driven Behavior**
The app is built to respond to commands from your backend API, allowing for dynamic control over the user experience:
* **Maintenance Mode:** Displays a full-screen "kill switch" page when the backend signals that the service is temporarily unavailable.
* **Forced Updates:** Shows a non-dismissible "Update Required" screen when a new version is mandatory, guiding users to the app store.
> **Your Advantage:** The client-side logic to handle critical operational states is already implemented. Your app can gracefully manage server downtime and enforce version updates without you needing to code these complex, full-screen takeover flows. 🛠️

#### 📱 **Adaptive UI for All Screens**
Built with `flutter_adaptive_scaffold`, the app offers responsive navigation and layouts that look great on both phones and tablets.
> **Your Advantage:** Deliver a consistent and optimized UX across a wide range of devices effortlessly. ↔️
Expand Down
129 changes: 66 additions & 63 deletions lib/app/bloc/app_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ class AppBloc extends Bloc<AppEvent, AppState> {
on<AppFontFamilyChanged>(_onFontFamilyChanged);
on<AppTextScaleFactorChanged>(_onAppTextScaleFactorChanged);
on<AppFontWeightChanged>(_onAppFontWeightChanged);
on<AppOpened>(_onAppOpened);

// Listen directly to the auth state changes stream
_userSubscription = _authenticationRepository.authStateChanges.listen(
Expand Down Expand Up @@ -412,67 +411,91 @@ class AppBloc extends Bloc<AppEvent, AppState> {
return;
}

// Avoid refetching if already loaded for the current user session, unless explicitly trying to recover from a failed state.
if (state.remoteConfig != null &&
state.status != AppStatus.configFetchFailed) {
// For background checks, we don't want to show a loading screen.
// Only for the initial fetch should we set the status to configFetching.
if (!event.isBackgroundCheck) {
print(
'[AppBloc] AppConfig already loaded for user ${state.user?.id} and not in a failed state. Skipping fetch.',
'[AppBloc] Initial config fetch. Setting status to configFetching.',
);
return;
emit(
state.copyWith(
status: AppStatus.configFetching,
),
);
} else {
print('[AppBloc] Background config fetch. Proceeding silently.');
}

print(
'[AppBloc] Attempting to fetch AppConfig for user: ${state.user!.id}...',
);
emit(
state.copyWith(
status: AppStatus.configFetching,
remoteConfig: null,
clearAppConfig: true,
),
);

try {
final remoteConfig = await _appConfigRepository.read(id: kRemoteConfigId);
print(
'[AppBloc] Remote Config fetched successfully. ID: ${remoteConfig.id} for user: ${state.user!.id}',
);

// Determine the correct status based on the existing user's role.
// This ensures that successfully fetching config doesn't revert auth status to 'initial'.
final newStatusBasedOnUser =
state.user!.appRole == AppUserRole.standardUser
? AppStatus.authenticated
: AppStatus.anonymous;
emit(
state.copyWith(
remoteConfig: remoteConfig,
status: newStatusBasedOnUser,
),
);
// --- CRITICAL STATUS EVALUATION ---
// For both initial and background checks, if a critical status is found,
// we must update the app status immediately to lock the UI.

// 1. Check for Maintenance Mode. This has the highest priority.
if (remoteConfig.appStatus.isUnderMaintenance) {
emit(
state.copyWith(
status: AppStatus.underMaintenance,
remoteConfig: remoteConfig,
),
);
return;
}

// 2. Check for a Required Update.
// TODO(fulleni): Compare with actual app version from package_info_plus.
if (remoteConfig.appStatus.isLatestVersionOnly) {
emit(
state.copyWith(
status: AppStatus.updateRequired,
remoteConfig: remoteConfig,
),
);
return;
}

// --- POST-CHECK STATE RESOLUTION ---
// If no critical status was found, we resolve the final state.

// For an initial fetch, we transition from configFetching to the correct
// authenticated/anonymous state.
if (!event.isBackgroundCheck) {
final finalStatus = state.user!.appRole == AppUserRole.standardUser
? AppStatus.authenticated
: AppStatus.anonymous;
emit(state.copyWith(remoteConfig: remoteConfig, status: finalStatus));
} else {
// For a background check, the status is already correct (e.g., authenticated).
// We just need to update the remoteConfig in the state silently.
// The status does not need to change, preventing a disruptive UI rebuild.
emit(state.copyWith(remoteConfig: remoteConfig));
}
} on HttpException catch (e) {
print(
'[AppBloc] Failed to fetch AppConfig (HttpException) for user ${state.user?.id}: ${e.runtimeType} - ${e.message}',
);
emit(
state.copyWith(
status: AppStatus.configFetchFailed,
remoteConfig: null,
clearAppConfig: true,
),
);
// Only show a failure screen on an initial fetch.
// For background checks, we fail silently to avoid disruption.
if (!event.isBackgroundCheck) {
emit(state.copyWith(status: AppStatus.configFetchFailed));
} else {
print('[AppBloc] Silent failure on background config fetch.');
}
} catch (e, s) {
print(
'[AppBloc] Unexpected error fetching AppConfig for user ${state.user?.id}: $e',
);
print('[AppBloc] Stacktrace: $s');
emit(
state.copyWith(
status: AppStatus.configFetchFailed,
remoteConfig: null,
clearAppConfig: true,
),
);
if (!event.isBackgroundCheck) {
emit(state.copyWith(status: AppStatus.configFetchFailed));
} else {
print('[AppBloc] Silent failure on background config fetch.');
}
}
}

Expand Down Expand Up @@ -524,24 +547,4 @@ class AppBloc extends Bloc<AppEvent, AppState> {
);
}
}

Future<void> _onAppOpened(AppOpened event, Emitter<AppState> emit) async {
if (state.remoteConfig == null) {
return;
}

final appStatus = state.remoteConfig!.appStatus;

if (appStatus.isUnderMaintenance) {
emit(state.copyWith(status: AppStatus.underMaintenance));
return;
}

// TODO(fulleni): Get the current app version from a package like
// package_info_plus and compare it with appStatus.latestAppVersion.
if (appStatus.isLatestVersionOnly) {
emit(state.copyWith(status: AppStatus.updateRequired));
return;
}
}
}
94 changes: 27 additions & 67 deletions lib/app/bloc/app_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ abstract class AppEvent extends Equatable {
List<Object?> get props => [];
}

/// Dispatched when the authentication state changes (e.g., user logs in/out).
class AppUserChanged extends AppEvent {
const AppUserChanged(this.user);

Expand All @@ -16,122 +17,81 @@ class AppUserChanged extends AppEvent {
List<Object?> get props => [user];
}

/// {@template app_settings_refreshed}
/// Internal event to trigger reloading of settings within AppBloc.
/// Added when user changes or upon explicit request.
/// {@endtemplate}
/// Dispatched to request a refresh of the user's application settings.
class AppSettingsRefreshed extends AppEvent {
/// {@macro app_settings_refreshed}
const AppSettingsRefreshed();
}

/// {@template app_logout_requested}
/// Event to request user logout.
/// {@endtemplate}
/// Dispatched to fetch the remote application configuration.
class AppConfigFetchRequested extends AppEvent {
const AppConfigFetchRequested({this.isBackgroundCheck = false});

/// Whether this fetch is a silent background check.
///
/// If `true`, the BLoC will not enter a visible loading state.
/// If `false` (default), it's treated as an initial fetch that shows a
/// loading UI.
final bool isBackgroundCheck;

@override
List<Object> get props => [isBackgroundCheck];
}

/// Dispatched when the user logs out.
class AppLogoutRequested extends AppEvent {
/// {@macro app_logout_requested}
const AppLogoutRequested();
}

/// {@template app_theme_mode_changed}
/// Event to change the application's theme mode.
/// {@endtemplate}
/// Dispatched when the theme mode (light/dark/system) changes.
class AppThemeModeChanged extends AppEvent {
/// {@macro app_theme_mode_changed}
const AppThemeModeChanged(this.themeMode);

final ThemeMode themeMode;

@override
List<Object?> get props => [themeMode];
List<Object> get props => [themeMode];
}

/// {@template app_flex_scheme_changed}
/// Event to change the application's FlexColorScheme.
/// {@endtemplate}
/// Dispatched when the accent color theme changes.
class AppFlexSchemeChanged extends AppEvent {
/// {@macro app_flex_scheme_changed}
const AppFlexSchemeChanged(this.flexScheme);

final FlexScheme flexScheme;

@override
List<Object?> get props => [flexScheme];
List<Object> get props => [flexScheme];
}

/// {@template app_font_family_changed}
/// Event to change the application's font family.
/// {@endtemplate}
/// Dispatched when the font family changes.
class AppFontFamilyChanged extends AppEvent {
/// {@macro app_font_family_changed}
const AppFontFamilyChanged(this.fontFamily);

final String? fontFamily;

@override
List<Object?> get props => [fontFamily];
}

/// {@template app_text_scale_factor_changed}
/// Event to change the application's text scale factor.
/// {@endtemplate}
/// Dispatched when the text scale factor changes.
class AppTextScaleFactorChanged extends AppEvent {
/// {@macro app_text_scale_factor_changed}
const AppTextScaleFactorChanged(this.appTextScaleFactor);

final AppTextScaleFactor appTextScaleFactor;

@override
List<Object?> get props => [appTextScaleFactor];
List<Object> get props => [appTextScaleFactor];
}

/// {@template app_font_weight_changed}
/// Event to change the application's font weight.
/// {@endtemplate}
/// Dispatched when the font weight changes.
class AppFontWeightChanged extends AppEvent {
/// {@macro app_font_weight_changed}
const AppFontWeightChanged(this.fontWeight);

/// The new font weight to apply.
final AppFontWeight fontWeight;

@override
List<Object> get props => [fontWeight];
}

/// {@template app_config_fetch_requested}
/// Event to trigger fetching of the global AppConfig.
/// {@endtemplate}
class AppConfigFetchRequested extends AppEvent {
/// {@macro app_config_fetch_requested}
const AppConfigFetchRequested();
}

/// {@template app_opened}
/// Event triggered when the application is opened.
/// Used to check for required updates or maintenance mode.
/// {@endtemplate}
class AppOpened extends AppEvent {
/// {@macro app_opened}
const AppOpened();
}

/// {@template app_user_account_action_shown}
/// Event triggered when an AccountAction has been shown to the user,
/// prompting an update to their `lastAccountActionShownAt` timestamp.
/// {@endtemplate}
/// Dispatched when a one-time user account action has been shown.
class AppUserAccountActionShown extends AppEvent {
/// {@macro app_user_account_action_shown}
const AppUserAccountActionShown({
required this.userId,
required this.feedActionType,
required this.isCompleted,
});

final String userId;
final FeedActionType feedActionType;
final bool isCompleted;

@override
List<Object> get props => [userId, feedActionType, isCompleted];
}
Loading
Loading