From 91556e25d7712eb3308b9fa38055f6d7e9814cc2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 11:08:22 +0100 Subject: [PATCH 01/94] refactor(bootstrap): restructure app initialization process - Initialize HttpClient early to break circular dependency with AuthRepository - Add RemoteConfig initialization as a separate step - Fetch initial RemoteConfig before other services initialization - Adjust comments and naming for clarity --- lib/bootstrap.dart | 116 ++++++++++++++++++++++----------------------- 1 file changed, 57 insertions(+), 59 deletions(-) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 3c21156d..a579297b 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -51,12 +51,54 @@ Future bootstrap( // It will be fully configured once AdService is available. late final InlineAdCacheService inlineAdCacheService; - // 2. Conditionally initialize HttpClient and Auth services based on environment. - // This ensures HttpClient is available before any DataApi or AdProvider - // that depends on it. + // 2. Initialize HttpClient. Its tokenProvider now directly reads from + // kvStorage, breaking the circular dependency with AuthRepository. + // This HttpClient instance is used for all subsequent API calls, including + // the initial unauthenticated fetch of RemoteConfig. + final httpClient = HttpClient( + baseUrl: appConfig.baseUrl, + tokenProvider: () => + kvStorage.readString(key: StorageKey.authToken.stringValue), + logger: logger, + ); + + // 3. Initialize RemoteConfigClient and Repository, and fetch RemoteConfig. + // This is done early because RemoteConfig is now publicly accessible (unauthenticated). + late DataClient remoteConfigClient; + if (appConfig.environment == app_config.AppEnvironment.demo) { + remoteConfigClient = DataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: remoteConfigsFixturesData, + logger: logger, + ); + } else { + // For development and production environments, use DataApi. + remoteConfigClient = DataApi( + httpClient: httpClient, + modelName: 'remote_config', + fromJson: RemoteConfig.fromJson, + toJson: (config) => config.toJson(), + logger: logger, + ); + } + final remoteConfigRepository = DataRepository( + dataClient: remoteConfigClient, + ); + + // Fetch the initial RemoteConfig. This is a critical step to determine + // the app's global status (e.g., maintenance mode, update required) + // before proceeding with other initializations. + final initialRemoteConfig = await remoteConfigRepository.read( + id: kRemoteConfigId, + ); + logger.info('[bootstrap] Initial RemoteConfig fetched successfully.'); + + // 4. Conditionally initialize Auth services based on environment. + // This is done after RemoteConfig is fetched, as Auth services might depend + // on configurations defined in RemoteConfig (though not directly in this case). late final AuthClient authClient; late final AuthRepository authenticationRepository; - late final HttpClient httpClient; if (appConfig.environment == app_config.AppEnvironment.demo) { // In-memory authentication for demo environment. authClient = AuthInmemory(); @@ -64,26 +106,7 @@ Future bootstrap( authClient: authClient, storageService: kvStorage, ); - // For demo, httpClient is not strictly needed for DataApi, - // but we initialize a dummy one to satisfy non-nullable requirements - // if any part of the code path expects it. - // In a real scenario, DataApi would not be used in demo mode. - httpClient = HttpClient( - baseUrl: appConfig.baseUrl, - tokenProvider: () async => null, // No token needed for demo - logger: logger, - ); } else { - // For production and development environments, an HTTP client is needed. - // Initialize HttpClient first. Its tokenProvider now directly reads from - // kvStorage, breaking the circular dependency with AuthRepository. - httpClient = HttpClient( - baseUrl: appConfig.baseUrl, - tokenProvider: () => - kvStorage.readString(key: StorageKey.authToken.stringValue), - logger: logger, - ); - // Now that httpClient is available, initialize AuthApi and AuthRepository. authClient = AuthApi(httpClient: httpClient); authenticationRepository = AuthRepository( @@ -92,9 +115,7 @@ Future bootstrap( ); } - // 3. Initialize AdProvider and AdService. - // These now have a guaranteed valid httpClient (for DataApi-based LocalAdProvider) - // or can proceed independently (AdMobAdProvider). + // 5. Initialize AdProvider and AdService. late final Map adProviders; // Conditionally instantiate ad providers based on the application environment. @@ -153,17 +174,16 @@ Future bootstrap( // and InterstitialAdManager for BuildContext access. final navigatorKey = GlobalKey(); - // 4. Initialize all other DataClients and Repositories. + // 6. Initialize all other DataClients and Repositories. // These now also have a guaranteed valid httpClient. - DataClient headlinesClient; - DataClient topicsClient; - DataClient countriesClient; - DataClient sourcesClient; - DataClient userContentPreferencesClient; - DataClient userAppSettingsClient; - DataClient remoteConfigClient; - DataClient userClient; - DataClient localAdClient; + late final DataClient headlinesClient; + late final DataClient topicsClient; + late final DataClient countriesClient; + late final DataClient sourcesClient; + late final DataClient userContentPreferencesClient; + late final DataClient userAppSettingsClient; + late final DataClient userClient; + late final DataClient localAdClient; if (appConfig.environment == app_config.AppEnvironment.demo) { headlinesClient = DataInMemory( toJson: (i) => i.toJson(), @@ -228,12 +248,6 @@ Future bootstrap( getId: (i) => i.id, logger: logger, ); - remoteConfigClient = DataInMemory( - toJson: (i) => i.toJson(), - getId: (i) => i.id, - initialData: remoteConfigsFixturesData, - logger: logger, - ); userClient = DataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, @@ -288,13 +302,6 @@ Future bootstrap( toJson: (settings) => settings.toJson(), logger: logger, ); - remoteConfigClient = DataApi( - httpClient: httpClient, - modelName: 'remote_config', - fromJson: RemoteConfig.fromJson, - toJson: (config) => config.toJson(), - logger: logger, - ); userClient = DataApi( httpClient: httpClient, modelName: 'user', @@ -353,13 +360,6 @@ Future bootstrap( toJson: (settings) => settings.toJson(), logger: logger, ); - remoteConfigClient = DataApi( - httpClient: httpClient, - modelName: 'remote_config', - fromJson: RemoteConfig.fromJson, - toJson: (config) => config.toJson(), - logger: logger, - ); userClient = DataApi( httpClient: httpClient, modelName: 'user', @@ -392,9 +392,6 @@ Future bootstrap( final userAppSettingsRepository = DataRepository( dataClient: userAppSettingsClient, ); - final remoteConfigRepository = DataRepository( - dataClient: remoteConfigClient, - ); final userRepository = DataRepository(dataClient: userClient); // Conditionally instantiate DemoDataMigrationService @@ -435,5 +432,6 @@ Future bootstrap( initialUser: initialUser, localAdRepository: localAdRepository, navigatorKey: navigatorKey, // Pass the navigatorKey to App + initialRemoteConfig: initialRemoteConfig, // Pass the initialRemoteConfig ); } From 0f71f7a37343530c4a9d9d86d5505f0b892849f4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 11:08:33 +0100 Subject: [PATCH 02/94] feat(app): enhance App widget documentation and functionality - Add detailed documentation to the App widget class - Implement initialRemoteConfig parameter in App widget constructor - Update AppBloc creation to include initialRemoteConfig and navigatorKey - Improve code readability and formatting --- lib/app/view/app.dart | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 543b1719..6b777c04 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -20,7 +20,16 @@ import 'package:go_router/go_router.dart'; import 'package:kv_storage_service/kv_storage_service.dart'; import 'package:ui_kit/ui_kit.dart'; +/// {@template app_widget} +/// The root widget of the application. +/// +/// This widget is responsible for setting up the dependency injection +/// (RepositoryProviders and BlocProviders) for the entire application. +/// It also orchestrates the initial application startup flow, passing +/// pre-fetched data and services to the [AppBloc] and [_AppView]. +/// {@endtemplate} class App extends StatelessWidget { + /// {@macro app_widget} const App({ required AuthRepository authenticationRepository, required DataRepository headlinesRepository, @@ -38,6 +47,7 @@ class App extends StatelessWidget { required DataRepository localAdRepository, required GlobalKey navigatorKey, required InlineAdCacheService inlineAdCacheService, + required RemoteConfig initialRemoteConfig, this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, @@ -56,7 +66,8 @@ class App extends StatelessWidget { _adService = adService, _localAdRepository = localAdRepository, _navigatorKey = navigatorKey, - _inlineAdCacheService = inlineAdCacheService; + _inlineAdCacheService = inlineAdCacheService, + _initialRemoteConfig = initialRemoteConfig; final AuthRepository _authenticationRepository; final DataRepository _headlinesRepository; @@ -77,6 +88,9 @@ class App extends StatelessWidget { final DemoDataMigrationService? demoDataMigrationService; final DemoDataInitializerService? demoDataInitializerService; final User? initialUser; + /// The remote configuration fetched during the app's bootstrap phase. + /// This is used to initialize the AppBloc with the global app status. + final RemoteConfig _initialRemoteConfig; @override Widget build(BuildContext context) { @@ -110,6 +124,7 @@ class App extends StatelessWidget { demoDataInitializerService: demoDataInitializerService, initialUser: initialUser, navigatorKey: _navigatorKey, // Pass navigatorKey to AppBloc + initialRemoteConfig: _initialRemoteConfig, // Pass initialRemoteConfig ), ), BlocProvider( @@ -117,9 +132,9 @@ class App extends StatelessWidget { authenticationRepository: context.read(), ), ), - // Provide the InterstitialAdManager as a RepositoryProvider - // it depends on the state managed by AppBloc. Therefore, - // so it must be created after AppBloc is available. + // Provide the InterstitialAdManager as a RepositoryProvider. + // It depends on the state managed by AppBloc, so it must be created + // after AppBloc is available. RepositoryProvider( create: (context) => InterstitialAdManager( appBloc: context.read(), @@ -195,12 +210,15 @@ class _AppViewState extends State<_AppView> { void initState() { super.initState(); final appBloc = context.read(); - // Initialize the notifier with the BLoC's current state + // Initialize the notifier with the BLoC's current state. + // This notifier is used by GoRouter's refreshListenable to trigger + // route re-evaluation when the app's lifecycle status changes. _statusNotifier = ValueNotifier(appBloc.state.status); // Instantiate and initialize the AppStatusService. - // This service will automatically trigger checks when the app is resumed - // or at periodic intervals, ensuring the app status is always fresh. + // This service monitors the app's lifecycle and periodically triggers + // remote configuration fetches via the AppBloc, ensuring the app status + // is always fresh (e.g., detecting maintenance mode or forced updates). _appStatusService = AppStatusService( context: context, checkInterval: const Duration(minutes: 15), From 1f8fe32d6b38edcc6a907a5614d0c6b8140c1f60 Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 11:08:57 +0100 Subject: [PATCH 03/94] refactor(AppBloc): enhance initial bootstrap with remote config - Add initialRemoteConfig parameter to AppBloc constructor - Update initial state to use pre-fetched remoteConfig - Refactor user authentication and config fetching logic - Remove redundant remoteConfig fetch and update - Optimize state transitions and data flow --- lib/app/bloc/app_bloc.dart | 133 +++++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 51 deletions(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 0f56b65f..0ae91c2e 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -16,7 +16,19 @@ import 'package:logging/logging.dart'; part 'app_event.dart'; part 'app_state.dart'; +/// {@template app_bloc} +/// Manages the overall application state, including authentication status, +/// user settings, and remote configuration. +/// +/// This BLoC is central to the application's lifecycle, reacting to user +/// authentication changes, managing user preferences, and applying global +/// remote configurations. +/// {@endtemplate} class AppBloc extends Bloc { + /// {@macro app_bloc} + /// + /// Initializes the BLoC with required repositories, environment, and + /// pre-fetched initial data. AppBloc({ required AuthRepository authenticationRepository, required DataRepository userAppSettingsRepository, @@ -24,6 +36,7 @@ class AppBloc extends Bloc { required DataRepository userRepository, required local_config.AppEnvironment environment, required GlobalKey navigatorKey, + required RemoteConfig initialRemoteConfig, this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, @@ -35,15 +48,19 @@ class AppBloc extends Bloc { _navigatorKey = navigatorKey, _logger = Logger('AppBloc'), super( - // The initial state of the app. The status is set based on whether - // an initial user is provided. If a user exists, it immediately - // transitions to `configFetching` to show the loading UI. - // Default settings are provided as a fallback until user-specific - // settings are loaded via the `AppUserChanged` event. + // The initial state of the app. The status is set based on the + // initialRemoteConfig and whether an initial user is provided. + // This ensures critical app status (maintenance, update) is checked + // immediately upon app launch, before any user-specific data is loaded. AppState( - status: initialUser == null + status: initialRemoteConfig.appStatus.isUnderMaintenance + ? AppLifeCycleStatus.underMaintenance + : initialRemoteConfig.appStatus.isLatestVersionOnly + ? AppLifeCycleStatus.updateRequired + : initialUser == null ? AppLifeCycleStatus.unauthenticated - : AppLifeCycleStatus.configFetching, + : AppLifeCycleStatus + .authenticated, // Assuming authenticated if user exists and no other critical status settings: UserAppSettings( id: 'default', displaySettings: const DisplaySettings( @@ -67,10 +84,20 @@ class AppBloc extends Bloc { ), ), selectedBottomNavigationIndex: 0, - remoteConfig: null, + remoteConfig: initialRemoteConfig, // Use the pre-fetched config environment: environment, - // The `user` is intentionally not set here. It will be set by - // the `AppUserChanged` event, which is triggered by the auth stream. + user: initialUser, // Set initial user if available + themeMode: _mapAppBaseTheme( + initialUser?.appRole == AppUserRole.guestUser + ? AppBaseTheme.system + : AppBaseTheme.system, // Default to system theme + ), + flexScheme: _mapAppAccentTheme(AppAccentTheme.defaultBlue), + fontFamily: _mapFontFamily('SystemDefault'), + appTextScaleFactor: AppTextScaleFactor.medium, + locale: Locale( + initialUser?.appRole == AppUserRole.guestUser ? 'en' : 'en', + ), // Default to English ), ) { // Register event handlers for various app-level events. @@ -118,11 +145,12 @@ class AppBloc extends Bloc { /// 2. If the user is `null`, it will emit an `unauthenticated` state. /// 3. If a user is present, it will immediately emit a `configFetching` /// state to display the loading UI. - /// 4. It will then proceed to fetch the `remoteConfig` and `userAppSettings` - /// concurrently. + /// 4. It will then proceed to fetch the `userAppSettings` (RemoteConfig is + /// already available from initial bootstrap). /// 5. Upon successful fetch, it will evaluate the app's status (maintenance, - /// update required) and emit the final stable state (`authenticated`, - /// `anonymous`, etc.) with the correct, freshly-loaded data. + /// update required) using the *already available* `remoteConfig` and + /// emit the final stable state (`authenticated`, `anonymous`, etc.) + /// with the correct, freshly-loaded data. /// 6. If any fetch fails, it will emit a `configFetchFailed` state, /// allowing the user to retry. Future _onAppUserChanged( @@ -150,8 +178,8 @@ class AppBloc extends Bloc { state.copyWith( status: AppLifeCycleStatus.unauthenticated, user: null, - remoteConfig: null, - clearAppConfig: true, // Explicitly clear remoteConfig + // RemoteConfig is now managed by initial bootstrap and AppConfigFetchRequested. + // It should not be cleared here. ), ); return; @@ -165,7 +193,7 @@ class AppBloc extends Bloc { ); // Immediately emit the new user and set status to configFetching. - // This ensures the UI shows a loading state while we fetch data. + // This ensures the UI shows a loading state while we fetch user-specific data. emit( state.copyWith(user: newUser, status: AppLifeCycleStatus.configFetching), ); @@ -204,18 +232,15 @@ class AppBloc extends Bloc { // --- Fetch Core Application Data --- try { - // Fetch remote config and user settings concurrently for performance. - final results = await Future.wait([ - _appConfigRepository.read(id: kRemoteConfigId), - _userAppSettingsRepository.read(id: newUser.id, userId: newUser.id), - ]); - - final remoteConfig = results[0] as RemoteConfig; - final userAppSettings = results[1] as UserAppSettings; + // Only fetch user settings. RemoteConfig is already available from bootstrap + // and is stored in the current state. + final userAppSettings = await _userAppSettingsRepository.read( + id: newUser.id, + userId: newUser.id, + ); _logger.info( - '[AppBloc] RemoteConfig and UserAppSettings fetched successfully for ' - 'user: ${newUser.id}', + '[AppBloc] UserAppSettings fetched successfully for user: ${newUser.id}', ); // Map loaded settings to the AppState. @@ -233,12 +258,16 @@ class AppBloc extends Bloc { ); final newLocale = Locale(userAppSettings.language.code); - // --- CRITICAL STATUS EVALUATION --- + // --- CRITICAL STATUS EVALUATION (using already available remoteConfig) --- + // The remoteConfig is already in the state from the initial bootstrap. + // We use the existing state.remoteConfig to perform these checks. + final remoteConfig = state.remoteConfig!; + if (remoteConfig.appStatus.isUnderMaintenance) { emit( state.copyWith( status: AppLifeCycleStatus.underMaintenance, - remoteConfig: remoteConfig, + // remoteConfig: remoteConfig, // Already in state, no need to re-assign settings: userAppSettings, themeMode: newThemeMode, flexScheme: newFlexScheme, @@ -255,7 +284,7 @@ class AppBloc extends Bloc { emit( state.copyWith( status: AppLifeCycleStatus.updateRequired, - remoteConfig: remoteConfig, + // remoteConfig: remoteConfig, // Already in state, no need to re-assign settings: userAppSettings, themeMode: newThemeMode, flexScheme: newFlexScheme, @@ -276,7 +305,7 @@ class AppBloc extends Bloc { emit( state.copyWith( status: finalStatus, - remoteConfig: remoteConfig, + // remoteConfig: remoteConfig, // Already in state, no need to re-assign settings: userAppSettings, themeMode: newThemeMode, flexScheme: newFlexScheme, @@ -376,8 +405,8 @@ class AppBloc extends Bloc { baseTheme: event.themeMode == ThemeMode.light ? AppBaseTheme.light : (event.themeMode == ThemeMode.dark - ? AppBaseTheme.dark - : AppBaseTheme.system), + ? AppBaseTheme.dark + : AppBaseTheme.system), ), ); emit(state.copyWith(settings: updatedSettings, themeMode: event.themeMode)); @@ -407,8 +436,8 @@ class AppBloc extends Bloc { accentTheme: event.flexScheme == FlexScheme.blue ? AppAccentTheme.defaultBlue : (event.flexScheme == FlexScheme.red - ? AppAccentTheme.newsRed - : AppAccentTheme.graphiteGray), + ? AppAccentTheme.newsRed + : AppAccentTheme.graphiteGray), ), ); emit( @@ -523,7 +552,7 @@ class AppBloc extends Bloc { } /// Maps [AppBaseTheme] enum to Flutter's [ThemeMode]. - ThemeMode _mapAppBaseTheme(AppBaseTheme mode) { + static ThemeMode _mapAppBaseTheme(AppBaseTheme mode) { switch (mode) { case AppBaseTheme.light: return ThemeMode.light; @@ -535,7 +564,7 @@ class AppBloc extends Bloc { } /// Maps [AppAccentTheme] enum to FlexColorScheme's [FlexScheme]. - FlexScheme _mapAppAccentTheme(AppAccentTheme name) { + static FlexScheme _mapAppAccentTheme(AppAccentTheme name) { switch (name) { case AppAccentTheme.defaultBlue: return FlexScheme.blue; @@ -547,19 +576,15 @@ class AppBloc extends Bloc { } /// Maps a font family string to a nullable string for theme data. - String? _mapFontFamily(String fontFamilyString) { + static String? _mapFontFamily(String fontFamilyString) { if (fontFamilyString == 'SystemDefault') { - _logger.info('_mapFontFamily: Input is SystemDefault, returning null.'); return null; } - _logger.info( - '_mapFontFamily: Input is $fontFamilyString, returning as is.', - ); return fontFamilyString; } /// Maps [AppTextScaleFactor] to itself (no transformation needed). - AppTextScaleFactor _mapTextScaleFactor(AppTextScaleFactor factor) { + static AppTextScaleFactor _mapTextScaleFactor(AppTextScaleFactor factor) { return factor; } @@ -570,17 +595,23 @@ class AppBloc extends Bloc { } /// Handles fetching the remote application configuration. + /// + /// This method is primarily used for re-fetching the remote configuration + /// (e.g., by [AppStatusService] for background checks or by [StatusPage] + /// for retries). The initial remote configuration is fetched during bootstrap. Future _onAppConfigFetchRequested( AppConfigFetchRequested event, Emitter emit, ) async { if (state.user == null) { _logger.info('[AppBloc] User is null. Skipping AppConfig fetch.'); + // If there's no user, and we're not already fetching, or if remoteConfig + // is present, we might transition to unauthenticated. if (state.remoteConfig != null || state.status == AppLifeCycleStatus.configFetching) { emit( state.copyWith( - remoteConfig: null, + remoteConfig: null, // Clear remoteConfig if unauthenticated clearAppConfig: true, status: AppLifeCycleStatus.unauthenticated, ), @@ -658,7 +689,7 @@ class AppBloc extends Bloc { final now = DateTime.now(); final currentStatus = originalUser.feedDecoratorStatus[event.feedDecoratorType] ?? - const UserFeedDecoratorStatus(isCompleted: false); + const UserFeedDecoratorStatus(isCompleted: false); final updatedDecoratorStatus = currentStatus.copyWith( lastShownAt: now, @@ -667,12 +698,12 @@ class AppBloc extends Bloc { final newFeedDecoratorStatus = Map.from( - originalUser.feedDecoratorStatus, - )..update( - event.feedDecoratorType, - (_) => updatedDecoratorStatus, - ifAbsent: () => updatedDecoratorStatus, - ); + originalUser.feedDecoratorStatus, + )..update( + event.feedDecoratorType, + (_) => updatedDecoratorStatus, + ifAbsent: () => updatedDecoratorStatus, + ); final updatedUser = originalUser.copyWith( feedDecoratorStatus: newFeedDecoratorStatus, From bec635f9e37a386e2f7a95498ea3ea4824f0f3d2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 16:13:55 +0100 Subject: [PATCH 04/94] refactor(data): inline countriesClient initialization - Remove standalone initialization of countriesClient with DataInMemory - Directly initialize countriesClient as CountryInMemoryClient - This change simplifies the code structure and improves readability --- lib/bootstrap.dart | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index a579297b..14a6a5e9 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -197,13 +197,6 @@ Future bootstrap( initialData: topicsFixturesData, logger: logger, ); - countriesClient = DataInMemory( - toJson: (i) => i.toJson(), - getId: (i) => i.id, - initialData: countriesFixturesData, - logger: logger, - ); - // Wrap the generic DataInMemory with CountryInMemoryClient. // This decorator adds specialized filtering for 'hasActiveSources' and // 'hasActiveHeadlines' which are specific to the application's needs @@ -227,10 +220,16 @@ Future bootstrap( // its reusability. The Decorator Pattern allows us to extend its // functionality for `Country` models without altering the generic base. countriesClient = CountryInMemoryClient( - decoratedClient: countriesClient, + decoratedClient: DataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: countriesFixturesData, + logger: logger, + ), allSources: sourcesFixturesData, allHeadlines: headlinesFixturesData, ); + // sourcesClient = DataInMemory( toJson: (i) => i.toJson(), From 95fc7bc8e3e20e4d653092d5d776883db0e4c05a Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 17:15:08 +0100 Subject: [PATCH 05/94] fix(bootstrap): handle remote config fetch errors - Add error handling for initial RemoteConfig fetch - Introduce RemoteConfig? initialRemoteConfig and HttpException? initialRemoteConfigError variables - Implement try-catch block to handle HttpException and other unexpected errors - Log errors and pass UnknownException for unexpected errors - Update App initialization to include initialRemoteConfigError --- lib/bootstrap.dart | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 14a6a5e9..63b46ef0 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -89,10 +89,27 @@ Future bootstrap( // Fetch the initial RemoteConfig. This is a critical step to determine // the app's global status (e.g., maintenance mode, update required) // before proceeding with other initializations. - final initialRemoteConfig = await remoteConfigRepository.read( - id: kRemoteConfigId, - ); - logger.info('[bootstrap] Initial RemoteConfig fetched successfully.'); + RemoteConfig? initialRemoteConfig; + HttpException? initialRemoteConfigError; + + try { + initialRemoteConfig = await remoteConfigRepository.read( + id: kRemoteConfigId, + ); + logger.info('[bootstrap] Initial RemoteConfig fetched successfully.'); + } on HttpException catch (e) { + logger.severe( + '[bootstrap] Failed to fetch initial RemoteConfig (HttpException): $e', + ); + initialRemoteConfigError = e; + } catch (e, s) { + logger.severe( + '[bootstrap] Unexpected error fetching initial RemoteConfig.', + e, + s, + ); + initialRemoteConfigError = UnknownException(e.toString()); + } // 4. Conditionally initialize Auth services based on environment. // This is done after RemoteConfig is fetched, as Auth services might depend @@ -432,5 +449,6 @@ Future bootstrap( localAdRepository: localAdRepository, navigatorKey: navigatorKey, // Pass the navigatorKey to App initialRemoteConfig: initialRemoteConfig, // Pass the initialRemoteConfig + initialRemoteConfigError: initialRemoteConfigError, // Pass the initialRemoteConfigError ); } From e0220e259f98b836bc3ca7737a4892cd41b1296b Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 17:15:41 +0100 Subject: [PATCH 06/94] refactor(status): replace StatusPage with CriticalErrorPage - Remove StatusPage and related loading state - Add CriticalErrorPage for handling critical errors during startup - Update exports in view.dart --- lib/status/view/critical_error_page.dart | 43 +++++++++++++++++++ lib/status/view/status_page.dart | 53 ------------------------ lib/status/view/view.dart | 2 +- 3 files changed, 44 insertions(+), 54 deletions(-) create mode 100644 lib/status/view/critical_error_page.dart delete mode 100644 lib/status/view/status_page.dart diff --git a/lib/status/view/critical_error_page.dart b/lib/status/view/critical_error_page.dart new file mode 100644 index 00000000..7e105f92 --- /dev/null +++ b/lib/status/view/critical_error_page.dart @@ -0,0 +1,43 @@ +import 'package:core/core.dart' show HttpException, UnknownException; +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template critical_error_page} +/// A page displayed to the user when a critical application error occurs +/// during startup, such as a failure to fetch remote configuration or +/// user settings. +/// +/// This page provides a clear error message and a retry option, allowing +/// the user to attempt to recover from transient issues. +/// {@endtemplate} +class CriticalErrorPage extends StatelessWidget { + /// {@macro critical_error_page} + const CriticalErrorPage({ + required this.exception, + required this.onRetry, + super.key, + }); + + /// The exception that caused the critical error. + final HttpException exception; + + /// A callback function to be executed when the user taps the retry button. + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: FailureStateWidget( + exception: exception, + retryButtonText: l10n.retryButtonText, + onRetry: onRetry, + ), + ), + ); + } +} diff --git a/lib/status/view/status_page.dart b/lib/status/view/status_page.dart deleted file mode 100644 index 05c12844..00000000 --- a/lib/status/view/status_page.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:core/core.dart' hide AppStatus; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; -import 'package:ui_kit/ui_kit.dart'; - -/// A page that serves as the root UI during the critical startup sequence. -/// -/// This widget is displayed *before* the main application's router and UI -/// shell are built. It is responsible for showing the user a clear status -/// while the remote configuration is being fetched, and it provides a way -/// for the user to retry if the fetch operation fails. -class StatusPage extends StatelessWidget { - /// {@macro status_page} - const StatusPage({super.key}); - - @override - Widget build(BuildContext context) { - // This page is a temporary root widget shown during the critical - // startup phase before the main app UI (and GoRouter) is built. - // It handles two key states: fetching the remote configuration and - // recovering from a failed fetch. - return Scaffold( - body: BlocBuilder( - builder: (context, state) { - final l10n = AppLocalizationsX(context).l10n; - - if (state.status == AppLifeCycleStatus.configFetching) { - // While fetching configuration, display a clear loading indicator. - // This uses a shared widget from the UI kit for consistency. - return LoadingStateWidget( - icon: Icons.settings_applications_outlined, - headline: l10n.headlinesFeedLoadingHeadline, - subheadline: l10n.pleaseWait, - ); - } - - // If fetching fails, show an error message with a retry option. - // This allows the user to recover from transient network issues. - return FailureStateWidget( - exception: const NetworkException(), // A generic network error - retryButtonText: l10n.retryButtonText, - onRetry: () { - // Dispatch the event to AppBloc to re-trigger the fetch. - context.read().add(const AppConfigFetchRequested()); - }, - ); - }, - ), - ); - } -} diff --git a/lib/status/view/view.dart b/lib/status/view/view.dart index 9ef41960..1b3fc58c 100644 --- a/lib/status/view/view.dart +++ b/lib/status/view/view.dart @@ -1,3 +1,3 @@ +export 'critical_error_page.dart'; export 'maintenance_page.dart'; -export 'status_page.dart'; export 'update_required_page.dart'; From ede7c04020bbd0981d2459e20533320543e23fa2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 17:16:49 +0100 Subject: [PATCH 07/94] feat(app): implement critical error handling - Add critical error state and page to AppBloc and UI - Update App widget to handle critical error scenario - Refactor status page logic to accommodate new error handling - Import necessary packages for error handling --- lib/app/view/app.dart | 111 +++++++++++++---------- lib/status/view/critical_error_page.dart | 2 +- 2 files changed, 63 insertions(+), 50 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 6b777c04..192a5f6f 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -15,7 +15,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/app/services/dem import 'package:flutter_news_app_mobile_client_full_source_code/authentication/bloc/authentication_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/router.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/status/view/view.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/status/view/view.dart'; // Import view.dart for CriticalErrorPage import 'package:go_router/go_router.dart'; import 'package:kv_storage_service/kv_storage_service.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -38,7 +38,7 @@ class App extends StatelessWidget { required DataRepository sourcesRepository, required DataRepository userAppSettingsRepository, required DataRepository - userContentPreferencesRepository, + userContentPreferencesRepository, required DataRepository remoteConfigRepository, required DataRepository userRepository, required KVStorageService kvStorageService, @@ -47,27 +47,29 @@ class App extends StatelessWidget { required DataRepository localAdRepository, required GlobalKey navigatorKey, required InlineAdCacheService inlineAdCacheService, - required RemoteConfig initialRemoteConfig, + required RemoteConfig? initialRemoteConfig, + required HttpException? initialRemoteConfigError, this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, super.key, - }) : _authenticationRepository = authenticationRepository, - _headlinesRepository = headlinesRepository, - _topicsRepository = topicsRepository, - _countriesRepository = countriesRepository, - _sourcesRepository = sourcesRepository, - _userAppSettingsRepository = userAppSettingsRepository, - _userContentPreferencesRepository = userContentPreferencesRepository, - _appConfigRepository = remoteConfigRepository, - _userRepository = userRepository, - _kvStorageService = kvStorageService, - _environment = environment, - _adService = adService, - _localAdRepository = localAdRepository, - _navigatorKey = navigatorKey, - _inlineAdCacheService = inlineAdCacheService, - _initialRemoteConfig = initialRemoteConfig; + }) : _authenticationRepository = authenticationRepository, + _headlinesRepository = headlinesRepository, + _topicsRepository = topicsRepository, + _countriesRepository = countriesRepository, + _sourcesRepository = sourcesRepository, + _userAppSettingsRepository = userAppSettingsRepository, + _userContentPreferencesRepository = userContentPreferencesRepository, + _appConfigRepository = remoteConfigRepository, + _userRepository = userRepository, + _kvStorageService = kvStorageService, + _environment = environment, + _adService = adService, + _localAdRepository = localAdRepository, + _navigatorKey = navigatorKey, + _inlineAdCacheService = inlineAdCacheService, + _initialRemoteConfig = initialRemoteConfig, + _initialRemoteConfigError = initialRemoteConfigError; final AuthRepository _authenticationRepository; final DataRepository _headlinesRepository; @@ -76,7 +78,7 @@ class App extends StatelessWidget { final DataRepository _sourcesRepository; final DataRepository _userAppSettingsRepository; final DataRepository - _userContentPreferencesRepository; + _userContentPreferencesRepository; final DataRepository _appConfigRepository; final DataRepository _userRepository; final KVStorageService _kvStorageService; @@ -88,9 +90,13 @@ class App extends StatelessWidget { final DemoDataMigrationService? demoDataMigrationService; final DemoDataInitializerService? demoDataInitializerService; final User? initialUser; + /// The remote configuration fetched during the app's bootstrap phase. /// This is used to initialize the AppBloc with the global app status. - final RemoteConfig _initialRemoteConfig; + final RemoteConfig? _initialRemoteConfig; + + /// Any error that occurred during the initial remote config fetch. + final HttpException? _initialRemoteConfigError; @override Widget build(BuildContext context) { @@ -115,8 +121,8 @@ class App extends StatelessWidget { BlocProvider( create: (context) => AppBloc( authenticationRepository: context.read(), - userAppSettingsRepository: context - .read>(), + userAppSettingsRepository: + context.read>(), appConfigRepository: context.read>(), userRepository: context.read>(), environment: _environment, @@ -124,7 +130,10 @@ class App extends StatelessWidget { demoDataInitializerService: demoDataInitializerService, initialUser: initialUser, navigatorKey: _navigatorKey, // Pass navigatorKey to AppBloc - initialRemoteConfig: _initialRemoteConfig, // Pass initialRemoteConfig + initialRemoteConfig: + _initialRemoteConfig, // Pass initialRemoteConfig + initialRemoteConfigError: + _initialRemoteConfigError, // Pass initialRemoteConfigError ), ), BlocProvider( @@ -277,21 +286,7 @@ class _AppViewState extends State<_AppView> { // By returning a dedicated widget here, we ensure these pages are // full-screen and exist outside the main app's navigation shell. - if (state.status == AppLifeCycleStatus.underMaintenance) { - // The app is in maintenance mode. Show the MaintenancePage. - // - // WHY A SEPARATE MATERIALAPP? - // Each status page is wrapped in its own simple MaterialApp to create - // a self-contained environment. This provides the necessary - // Directionality, theme, and localization context for the page - // to render correctly, without needing the main app's router. - // - // WHY A DEFAULT THEME? - // The theme uses hardcoded, sensible defaults (like FlexScheme.material) - // because at this early stage, we only need a basic visual structure. - // However, we critically use `state.themeMode` and `state.locale`, - // which are loaded from user settings *before* the maintenance check, - // ensuring the page respects the user's chosen light/dark mode and language. + if (state.status == AppLifeCycleStatus.criticalError) { return MaterialApp( debugShowCheckedModeBanner: false, theme: lightTheme( @@ -313,12 +308,33 @@ class _AppViewState extends State<_AppView> { ], supportedLocales: AppLocalizations.supportedLocales, locale: state.locale, - home: const MaintenancePage(), + home: CriticalErrorPage( + exception: state.initialRemoteConfigError ?? + const UnknownException('An unknown critical error occurred.'), + onRetry: () { + context + .read() + .add(const AppConfigFetchRequested(isBackgroundCheck: false)); + }, + ), ); } - if (state.status == AppLifeCycleStatus.updateRequired) { - // A mandatory update is required. Show the UpdateRequiredPage. + if (state.status == AppLifeCycleStatus.underMaintenance) { + // The app is in maintenance mode. Show the MaintenancePage. + // + // WHY A SEPARATE MATERIALAPP? + // Each status page is wrapped in its own simple MaterialApp to create + // a self-contained environment. This provides the necessary + // Directionality, theme, and localization context for the page + // to render correctly, without needing the main app's router. + // + // WHY A DEFAULT THEME? + // The theme uses hardcoded, sensible defaults (like FlexScheme.material) + // because at this early stage, we only need a basic visual structure. + // However, we critically use `state.themeMode` and `state.locale`, + // which are loaded from user settings *before* the maintenance check, + // ensuring the page respects the user's chosen light/dark mode and language. return MaterialApp( debugShowCheckedModeBanner: false, theme: lightTheme( @@ -340,15 +356,12 @@ class _AppViewState extends State<_AppView> { ], supportedLocales: AppLocalizations.supportedLocales, locale: state.locale, - home: const UpdateRequiredPage(), + home: const MaintenancePage(), ); } - if (state.status == AppLifeCycleStatus.configFetching || - state.status == AppLifeCycleStatus.configFetchFailed) { - // The app is in the process of fetching its initial remote - // configuration or has failed to do so. The StatusPage handles - // both the loading indicator and the retry mechanism. + if (state.status == AppLifeCycleStatus.updateRequired) { + // A mandatory update is required. Show the UpdateRequiredPage. return MaterialApp( debugShowCheckedModeBanner: false, theme: lightTheme( @@ -370,7 +383,7 @@ class _AppViewState extends State<_AppView> { ], supportedLocales: AppLocalizations.supportedLocales, locale: state.locale, - home: const StatusPage(), + home: const UpdateRequiredPage(), ); } diff --git a/lib/status/view/critical_error_page.dart b/lib/status/view/critical_error_page.dart index 7e105f92..8561955f 100644 --- a/lib/status/view/critical_error_page.dart +++ b/lib/status/view/critical_error_page.dart @@ -1,4 +1,4 @@ -import 'package:core/core.dart' show HttpException, UnknownException; +import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:ui_kit/ui_kit.dart'; From 7ac9ae409cb2983537807c5aacbff7f69d891684 Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 17:16:59 +0100 Subject: [PATCH 08/94] refactor(app): simplify app lifecycle and error handling - Remove initializing, configFetching, and configFetchFailed states - Add criticalError state to handle startup failures - Introduce initialRemoteConfigError in AppState to store fetch error - Update AppLifeCycleStatus and AppState constructors accordingly --- lib/app/bloc/app_state.dart | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index 04f1814f..ac81b091 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -6,10 +6,6 @@ part of 'app_bloc.dart'; /// and critical operations like fetching remote configuration or handling /// authentication changes. enum AppLifeCycleStatus { - /// The application is in the initial phase of bootstrapping, - /// fetching remote configuration and user settings. - initializing, - /// The user is not authenticated. unauthenticated, @@ -19,12 +15,9 @@ enum AppLifeCycleStatus { /// The user is anonymous (e.g., guest user). anonymous, - /// The application is currently fetching remote configuration. - /// This status is used for re-fetching or background checks, not initial load. - configFetching, - - /// The application failed to fetch remote configuration. - configFetchFailed, + /// A critical error occurred during application startup, + /// preventing normal operation. + criticalError, /// The application is currently under maintenance. underMaintenance, @@ -47,6 +40,7 @@ class AppState extends Equatable { required this.environment, this.user, this.remoteConfig, + this.initialRemoteConfigError, this.themeMode = ThemeMode.system, this.flexScheme = FlexScheme.blue, this.fontFamily, @@ -67,6 +61,9 @@ class AppState extends Equatable { /// The remote configuration fetched from the backend. final RemoteConfig? remoteConfig; + /// An error that occurred during the initial remote config fetch. + final HttpException? initialRemoteConfigError; + /// The current theme mode (light, dark, or system). final ThemeMode themeMode; @@ -94,6 +91,7 @@ class AppState extends Equatable { user, settings, remoteConfig, + initialRemoteConfigError, themeMode, flexScheme, fontFamily, @@ -111,6 +109,7 @@ class AppState extends Equatable { UserAppSettings? settings, RemoteConfig? remoteConfig, bool clearAppConfig = false, + HttpException? initialRemoteConfigError, ThemeMode? themeMode, FlexScheme? flexScheme, String? fontFamily, @@ -124,6 +123,8 @@ class AppState extends Equatable { user: user ?? this.user, settings: settings ?? this.settings, remoteConfig: clearAppConfig ? null : remoteConfig ?? this.remoteConfig, + initialRemoteConfigError: + initialRemoteConfigError ?? this.initialRemoteConfigError, themeMode: themeMode ?? this.themeMode, flexScheme: flexScheme ?? this.flexScheme, fontFamily: fontFamily ?? this.fontFamily, From 422fdbf737f744a118a0f0ac4983fcb7fc7b98fd Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 17:17:51 +0100 Subject: [PATCH 09/94] refactor(app): improve initial app config fetching and error handling - Add initialRemoteConfigError to constructor and state - Implement criticalError state for fatal fetch failures - Refactor config fetching logic to handle errors gracefully - Update state transitions and logging for various error scenarios - Remove redundant config fetching status and related comments --- lib/app/bloc/app_bloc.dart | 197 ++++++++++++++++++++++++++----------- 1 file changed, 138 insertions(+), 59 deletions(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 0ae91c2e..012a5eea 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -36,7 +36,8 @@ class AppBloc extends Bloc { required DataRepository userRepository, required local_config.AppEnvironment environment, required GlobalKey navigatorKey, - required RemoteConfig initialRemoteConfig, + required RemoteConfig? initialRemoteConfig, + required HttpException? initialRemoteConfigError, this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, @@ -49,20 +50,22 @@ class AppBloc extends Bloc { _logger = Logger('AppBloc'), super( // The initial state of the app. The status is set based on the - // initialRemoteConfig and whether an initial user is provided. - // This ensures critical app status (maintenance, update) is checked - // immediately upon app launch, before any user-specific data is loaded. + // initialRemoteConfig, initialRemoteConfigError, and whether an + // initial user is provided. This ensures critical app status + // (maintenance, update, critical error) is checked immediately + // upon app launch, before any user-specific data is loaded. AppState( - status: initialRemoteConfig.appStatus.isUnderMaintenance - ? AppLifeCycleStatus.underMaintenance - : initialRemoteConfig.appStatus.isLatestVersionOnly - ? AppLifeCycleStatus.updateRequired - : initialUser == null - ? AppLifeCycleStatus.unauthenticated - : AppLifeCycleStatus - .authenticated, // Assuming authenticated if user exists and no other critical status + status: initialRemoteConfigError != null + ? AppLifeCycleStatus.criticalError + : initialRemoteConfig?.appStatus.isUnderMaintenance ?? false + ? AppLifeCycleStatus.underMaintenance + : initialRemoteConfig?.appStatus.isLatestVersionOnly ?? false + ? AppLifeCycleStatus.updateRequired + : initialUser == null + ? AppLifeCycleStatus.unauthenticated + : AppLifeCycleStatus.authenticated, settings: UserAppSettings( - id: 'default', + id: 'default', // This will be replaced by actual user settings displaySettings: const DisplaySettings( baseTheme: AppBaseTheme.system, accentTheme: AppAccentTheme.defaultBlue, @@ -85,6 +88,7 @@ class AppBloc extends Bloc { ), selectedBottomNavigationIndex: 0, remoteConfig: initialRemoteConfig, // Use the pre-fetched config + initialRemoteConfigError: initialRemoteConfigError, // Store any initial config error environment: environment, user: initialUser, // Set initial user if available themeMode: _mapAppBaseTheme( @@ -143,15 +147,13 @@ class AppBloc extends Bloc { /// 1. It will first check if the user's ID has actually changed to prevent /// redundant reloads from simple token refreshes. /// 2. If the user is `null`, it will emit an `unauthenticated` state. - /// 3. If a user is present, it will immediately emit a `configFetching` - /// state to display the loading UI. - /// 4. It will then proceed to fetch the `userAppSettings` (RemoteConfig is - /// already available from initial bootstrap). + /// 3. If a user is present, it will immediately emit the new user. + /// 4. It will then proceed to fetch the `userAppSettings`. /// 5. Upon successful fetch, it will evaluate the app's status (maintenance, /// update required) using the *already available* `remoteConfig` and /// emit the final stable state (`authenticated`, `anonymous`, etc.) /// with the correct, freshly-loaded data. - /// 6. If any fetch fails, it will emit a `configFetchFailed` state, + /// 6. If any fetch fails, it will emit a `criticalError` state, /// allowing the user to retry. Future _onAppUserChanged( AppUserChanged event, @@ -178,8 +180,6 @@ class AppBloc extends Bloc { state.copyWith( status: AppLifeCycleStatus.unauthenticated, user: null, - // RemoteConfig is now managed by initial bootstrap and AppConfigFetchRequested. - // It should not be cleared here. ), ); return; @@ -192,11 +192,8 @@ class AppBloc extends Bloc { 'Beginning data fetch sequence.', ); - // Immediately emit the new user and set status to configFetching. - // This ensures the UI shows a loading state while we fetch user-specific data. - emit( - state.copyWith(user: newUser, status: AppLifeCycleStatus.configFetching), - ); + // Immediately emit the new user. + emit(state.copyWith(user: newUser)); // In demo mode, ensure user-specific data is initialized. if (_environment == local_config.AppEnvironment.demo && @@ -232,16 +229,45 @@ class AppBloc extends Bloc { // --- Fetch Core Application Data --- try { - // Only fetch user settings. RemoteConfig is already available from bootstrap - // and is stored in the current state. - final userAppSettings = await _userAppSettingsRepository.read( - id: newUser.id, - userId: newUser.id, - ); - - _logger.info( - '[AppBloc] UserAppSettings fetched successfully for user: ${newUser.id}', - ); + UserAppSettings userAppSettings; + try { + // Attempt to fetch user settings from the backend. + userAppSettings = await _userAppSettingsRepository.read( + id: newUser.id, + userId: newUser.id, + ); + _logger.info( + '[AppBloc] UserAppSettings fetched successfully for user: ${newUser.id}', + ); + } on HttpException catch (e) { + // If any HttpException occurs during read or create, transition to critical error. + _logger.severe( + '[AppBloc] Failed to fetch UserAppSettings for user ' + '${newUser.id}: ${e.runtimeType} - ${e.message}', + ); + emit( + state.copyWith( + status: AppLifeCycleStatus.criticalError, + initialRemoteConfigError: e, + ), + ); + return; // Critical error, stop further processing. + } catch (e, s) { + // Catch any other unexpected errors during read or create. + _logger.severe( + '[AppBloc] Unexpected error during UserAppSettings fetch/create for user ' + '${newUser.id}.', + e, + s, + ); + emit( + state.copyWith( + status: AppLifeCycleStatus.criticalError, + initialRemoteConfigError: UnknownException(e.toString()), + ), + ); + return; // Critical error, stop further processing. + } // Map loaded settings to the AppState. final newThemeMode = _mapAppBaseTheme( @@ -261,7 +287,26 @@ class AppBloc extends Bloc { // --- CRITICAL STATUS EVALUATION (using already available remoteConfig) --- // The remoteConfig is already in the state from the initial bootstrap. // We use the existing state.remoteConfig to perform these checks. - final remoteConfig = state.remoteConfig!; + final remoteConfig = state.remoteConfig; + + // If remoteConfig is null at this point, it means the initial fetch + // in bootstrap failed, and we should transition to a critical error state. + if (remoteConfig == null) { + emit( + state.copyWith( + status: AppLifeCycleStatus.criticalError, + initialRemoteConfigError: state.initialRemoteConfigError ?? + const UnknownException('RemoteConfig is null after user change.'), + settings: userAppSettings, + themeMode: newThemeMode, + flexScheme: newFlexScheme, + fontFamily: newFontFamily, + appTextScaleFactor: newAppTextScaleFactor, + locale: newLocale, + ), + ); + return; + } if (remoteConfig.appStatus.isUnderMaintenance) { emit( @@ -316,18 +361,28 @@ class AppBloc extends Bloc { ); } on HttpException catch (e) { _logger.severe( - '[AppBloc] Failed to fetch initial data (HttpException) for user ' + '[AppBloc] Failed to fetch or create UserAppSettings (HttpException) for user ' '${newUser.id}: ${e.runtimeType} - ${e.message}', ); - emit(state.copyWith(status: AppLifeCycleStatus.configFetchFailed)); + emit( + state.copyWith( + status: AppLifeCycleStatus.criticalError, + initialRemoteConfigError: e, + ), + ); } catch (e, s) { _logger.severe( - '[AppBloc] Unexpected error during initial data fetch for user ' + '[AppBloc] Unexpected error during UserAppSettings fetch/create for user ' '${newUser.id}.', e, s, ); - emit(state.copyWith(status: AppLifeCycleStatus.configFetchFailed)); + emit( + state.copyWith( + status: AppLifeCycleStatus.criticalError, + initialRemoteConfigError: UnknownException(e.toString()), + ), + ); } } @@ -383,10 +438,20 @@ class AppBloc extends Bloc { _logger.severe( 'Error loading user app settings in AppBloc (HttpException): $e', ); - emit(state.copyWith(settings: state.settings)); + emit( + state.copyWith( + status: AppLifeCycleStatus.criticalError, + initialRemoteConfigError: e, + ), + ); } catch (e, s) { _logger.severe('Error loading user app settings in AppBloc.', e, s); - emit(state.copyWith(settings: state.settings)); + emit( + state.copyWith( + status: AppLifeCycleStatus.criticalError, + initialRemoteConfigError: UnknownException(e.toString()), + ), + ); } } @@ -605,26 +670,22 @@ class AppBloc extends Bloc { ) async { if (state.user == null) { _logger.info('[AppBloc] User is null. Skipping AppConfig fetch.'); - // If there's no user, and we're not already fetching, or if remoteConfig - // is present, we might transition to unauthenticated. - if (state.remoteConfig != null || - state.status == AppLifeCycleStatus.configFetching) { - emit( - state.copyWith( - remoteConfig: null, // Clear remoteConfig if unauthenticated - clearAppConfig: true, - status: AppLifeCycleStatus.unauthenticated, - ), - ); - } + emit( + state.copyWith( + remoteConfig: null, + clearAppConfig: true, + status: AppLifeCycleStatus.unauthenticated, + initialRemoteConfigError: null, + ), + ); return; } if (!event.isBackgroundCheck) { _logger.info( - '[AppBloc] Initial config fetch. Setting status to configFetching.', + '[AppBloc] Initial config fetch. Setting status to criticalError if failed.', ); - emit(state.copyWith(status: AppLifeCycleStatus.configFetching)); + emit(state.copyWith(status: AppLifeCycleStatus.criticalError)); } else { _logger.info('[AppBloc] Background config fetch. Proceeding silently.'); } @@ -640,6 +701,7 @@ class AppBloc extends Bloc { state.copyWith( status: AppLifeCycleStatus.underMaintenance, remoteConfig: remoteConfig, + initialRemoteConfigError: null, ), ); return; @@ -651,6 +713,7 @@ class AppBloc extends Bloc { state.copyWith( status: AppLifeCycleStatus.updateRequired, remoteConfig: remoteConfig, + initialRemoteConfigError: null, ), ); return; @@ -659,13 +722,24 @@ class AppBloc extends Bloc { final finalStatus = state.user!.appRole == AppUserRole.standardUser ? AppLifeCycleStatus.authenticated : AppLifeCycleStatus.anonymous; - emit(state.copyWith(remoteConfig: remoteConfig, status: finalStatus)); + emit( + state.copyWith( + remoteConfig: remoteConfig, + status: finalStatus, + initialRemoteConfigError: null, + ), + ); } on HttpException catch (e) { _logger.severe( '[AppBloc] Failed to fetch AppConfig (HttpException) for user ${state.user?.id}: ${e.runtimeType} - ${e.message}', ); if (!event.isBackgroundCheck) { - emit(state.copyWith(status: AppLifeCycleStatus.configFetchFailed)); + emit( + state.copyWith( + status: AppLifeCycleStatus.criticalError, + initialRemoteConfigError: e, + ), + ); } } catch (e, s) { _logger.severe( @@ -674,7 +748,12 @@ class AppBloc extends Bloc { s, ); if (!event.isBackgroundCheck) { - emit(state.copyWith(status: AppLifeCycleStatus.configFetchFailed)); + emit( + state.copyWith( + status: AppLifeCycleStatus.criticalError, + initialRemoteConfigError: UnknownException(e.toString()), + ), + ); } } } From 4c9c770a602d93dd09144d4b5fd9c364a868522b Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 18:20:56 +0100 Subject: [PATCH 10/94] feat(app): add user preferences management in AppState - Add AppLifeCycleStatus.loadingUserData status - Introduce UserContentPreferences to store user preferences - Include initialUserPreferencesError in AppState - Update AppState constructor and copyWith method --- lib/app/bloc/app_state.dart | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index ac81b091..3ba7a029 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -6,6 +6,9 @@ part of 'app_bloc.dart'; /// and critical operations like fetching remote configuration or handling /// authentication changes. enum AppLifeCycleStatus { + /// The application is currently loading user-specific data (settings, preferences). + loadingUserData, + /// The user is not authenticated. unauthenticated, @@ -41,6 +44,8 @@ class AppState extends Equatable { this.user, this.remoteConfig, this.initialRemoteConfigError, + this.initialUserPreferencesError, + this.userContentPreferences, this.themeMode = ThemeMode.system, this.flexScheme = FlexScheme.blue, this.fontFamily, @@ -64,6 +69,12 @@ class AppState extends Equatable { /// An error that occurred during the initial remote config fetch. final HttpException? initialRemoteConfigError; + /// An error that occurred during the initial user preferences fetch. + final HttpException? initialUserPreferencesError; + + /// The user's content preferences, including followed countries, sources, topics, and saved headlines. + final UserContentPreferences? userContentPreferences; + /// The current theme mode (light, dark, or system). final ThemeMode themeMode; @@ -92,6 +103,8 @@ class AppState extends Equatable { settings, remoteConfig, initialRemoteConfigError, + initialUserPreferencesError, + userContentPreferences, themeMode, flexScheme, fontFamily, @@ -110,6 +123,8 @@ class AppState extends Equatable { RemoteConfig? remoteConfig, bool clearAppConfig = false, HttpException? initialRemoteConfigError, + HttpException? initialUserPreferencesError, + UserContentPreferences? userContentPreferences, ThemeMode? themeMode, FlexScheme? flexScheme, String? fontFamily, @@ -125,6 +140,10 @@ class AppState extends Equatable { remoteConfig: clearAppConfig ? null : remoteConfig ?? this.remoteConfig, initialRemoteConfigError: initialRemoteConfigError ?? this.initialRemoteConfigError, + initialUserPreferencesError: + initialUserPreferencesError ?? this.initialUserPreferencesError, + userContentPreferences: + userContentPreferences ?? this.userContentPreferences, themeMode: themeMode ?? this.themeMode, flexScheme: flexScheme ?? this.flexScheme, fontFamily: fontFamily ?? this.fontFamily, From 8a01f5f242a961ab9f81eb200bd58bdd4c581488 Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 18:21:08 +0100 Subject: [PATCH 11/94] feat(app): add AppStarted event for initial app bootstrap - Add AppStarted event to handle initial app start - Include optional initialUser parameter for pre-fetched user data - Update props to include initialUser --- lib/app/bloc/app_event.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart index 9f5b0403..622e17ce 100644 --- a/lib/app/bloc/app_event.dart +++ b/lib/app/bloc/app_event.dart @@ -7,6 +7,17 @@ abstract class AppEvent extends Equatable { List get props => []; } +/// Dispatched when the application is first started and ready to load initial data. +class AppStarted extends AppEvent { + const AppStarted({this.initialUser}); + + /// The user object pre-fetched during bootstrap, if available. + final User? initialUser; + + @override + List get props => [initialUser]; +} + /// Dispatched when the authentication state changes (e.g., user logs in/out). class AppUserChanged extends AppEvent { const AppUserChanged(this.user); From e966179a48608d0b757dac824433ba9b83da3533 Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 18:21:34 +0100 Subject: [PATCH 12/94] refactor(AppBloc): improve app startup and data loading process - Separate AppStarted and AppUserChanged events - Simplify initial state setup - Add UserContentPreferences fetching - Enhance error handling and reporting - Optimize demo data initialization and migration - Refactor status evaluation logic --- lib/app/bloc/app_bloc.dart | 459 ++++++++++++++++++------------------- 1 file changed, 217 insertions(+), 242 deletions(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 012a5eea..8fc6065e 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -32,6 +32,8 @@ class AppBloc extends Bloc { AppBloc({ required AuthRepository authenticationRepository, required DataRepository userAppSettingsRepository, + required DataRepository + userContentPreferencesRepository, required DataRepository appConfigRepository, required DataRepository userRepository, required local_config.AppEnvironment environment, @@ -41,70 +43,56 @@ class AppBloc extends Bloc { this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, - }) : _authenticationRepository = authenticationRepository, - _userAppSettingsRepository = userAppSettingsRepository, - _appConfigRepository = appConfigRepository, - _userRepository = userRepository, - _environment = environment, - _navigatorKey = navigatorKey, - _logger = Logger('AppBloc'), - super( - // The initial state of the app. The status is set based on the - // initialRemoteConfig, initialRemoteConfigError, and whether an - // initial user is provided. This ensures critical app status - // (maintenance, update, critical error) is checked immediately - // upon app launch, before any user-specific data is loaded. - AppState( - status: initialRemoteConfigError != null - ? AppLifeCycleStatus.criticalError - : initialRemoteConfig?.appStatus.isUnderMaintenance ?? false - ? AppLifeCycleStatus.underMaintenance - : initialRemoteConfig?.appStatus.isLatestVersionOnly ?? false - ? AppLifeCycleStatus.updateRequired - : initialUser == null - ? AppLifeCycleStatus.unauthenticated - : AppLifeCycleStatus.authenticated, - settings: UserAppSettings( - id: 'default', // This will be replaced by actual user settings - displaySettings: const DisplaySettings( - baseTheme: AppBaseTheme.system, - accentTheme: AppAccentTheme.defaultBlue, - fontFamily: 'SystemDefault', - textScaleFactor: AppTextScaleFactor.medium, - fontWeight: AppFontWeight.regular, - ), - language: languagesFixturesData.firstWhere( - (l) => l.code == 'en', - orElse: () => throw StateError( - 'Default language "en" not found in language fixtures.', - ), - ), - feedPreferences: const FeedDisplayPreferences( - headlineDensity: HeadlineDensity.standard, - headlineImageStyle: HeadlineImageStyle.largeThumbnail, - showSourceInHeadlineFeed: true, - showPublishDateInHeadlineFeed: true, - ), - ), - selectedBottomNavigationIndex: 0, - remoteConfig: initialRemoteConfig, // Use the pre-fetched config - initialRemoteConfigError: initialRemoteConfigError, // Store any initial config error - environment: environment, - user: initialUser, // Set initial user if available - themeMode: _mapAppBaseTheme( - initialUser?.appRole == AppUserRole.guestUser - ? AppBaseTheme.system - : AppBaseTheme.system, // Default to system theme - ), - flexScheme: _mapAppAccentTheme(AppAccentTheme.defaultBlue), - fontFamily: _mapFontFamily('SystemDefault'), - appTextScaleFactor: AppTextScaleFactor.medium, - locale: Locale( - initialUser?.appRole == AppUserRole.guestUser ? 'en' : 'en', - ), // Default to English - ), - ) { + }) : _authenticationRepository = authenticationRepository, + _userAppSettingsRepository = userAppSettingsRepository, + _userContentPreferencesRepository = userContentPreferencesRepository, + _appConfigRepository = appConfigRepository, + _userRepository = userRepository, + _environment = environment, + _navigatorKey = navigatorKey, + _logger = Logger('AppBloc'), + super( + // Initial state of the app. The status is set to loadingUserData + // as the AppBloc will now handle fetching user-specific data. + AppState( + status: AppLifeCycleStatus.loadingUserData, + settings: UserAppSettings( + id: 'default', // This will be replaced by actual user settings + displaySettings: const DisplaySettings( + baseTheme: AppBaseTheme.system, + accentTheme: AppAccentTheme.defaultBlue, + fontFamily: 'SystemDefault', + textScaleFactor: AppTextScaleFactor.medium, + fontWeight: AppFontWeight.regular, + ), + language: languagesFixturesData.firstWhere( + (l) => l.code == 'en', + orElse: () => throw StateError( + 'Default language "en" not found in language fixtures.', + ), + ), + feedPreferences: const FeedDisplayPreferences( + headlineDensity: HeadlineDensity.standard, + headlineImageStyle: HeadlineImageStyle.largeThumbnail, + showSourceInHeadlineFeed: true, + showPublishDateInHeadlineFeed: true, + ), + ), + selectedBottomNavigationIndex: 0, + remoteConfig: initialRemoteConfig, // Use the pre-fetched config + initialRemoteConfigError: + initialRemoteConfigError, // Store any initial config error + environment: environment, + user: initialUser, // Set initial user if available + themeMode: _mapAppBaseTheme(AppBaseTheme.system), // Default to system theme + flexScheme: _mapAppAccentTheme(AppAccentTheme.defaultBlue), + fontFamily: _mapFontFamily('SystemDefault'), + appTextScaleFactor: AppTextScaleFactor.medium, + locale: const Locale('en'), // Default to English + ), + ) { // Register event handlers for various app-level events. + on(_onAppStarted); // New event handler on(_onAppUserChanged); on(_onAppSettingsRefreshed); on(_onAppConfigFetchRequested); @@ -126,6 +114,7 @@ class AppBloc extends Bloc { final AuthRepository _authenticationRepository; final DataRepository _userAppSettingsRepository; + final DataRepository _userContentPreferencesRepository; final DataRepository _appConfigRepository; final DataRepository _userRepository; final local_config.AppEnvironment _environment; @@ -139,135 +128,98 @@ class AppBloc extends Bloc { /// Provides access to the [NavigatorState] for obtaining a [BuildContext]. GlobalKey get navigatorKey => _navigatorKey; - /// Handles all logic related to user authentication state changes. - /// - /// This method is the consolidated entry point for the app's startup - /// and data loading sequence, triggered exclusively by the auth stream. - /// - /// 1. It will first check if the user's ID has actually changed to prevent - /// redundant reloads from simple token refreshes. - /// 2. If the user is `null`, it will emit an `unauthenticated` state. - /// 3. If a user is present, it will immediately emit the new user. - /// 4. It will then proceed to fetch the `userAppSettings`. - /// 5. Upon successful fetch, it will evaluate the app's status (maintenance, - /// update required) using the *already available* `remoteConfig` and - /// emit the final stable state (`authenticated`, `anonymous`, etc.) - /// with the correct, freshly-loaded data. - /// 6. If any fetch fails, it will emit a `criticalError` state, - /// allowing the user to retry. - Future _onAppUserChanged( - AppUserChanged event, + /// Handles the [AppStarted] event, orchestrating the initial loading of + /// user-specific data and evaluating the overall app status. + Future _onAppStarted( + AppStarted event, Emitter emit, ) async { - final oldUser = state.user; + _logger.info('[AppBloc] AppStarted event received. Starting data load.'); - // Optimization: Prevent redundant reloads if the user ID hasn't changed. - // This can happen with token refreshes that re-emit the same user. - if (oldUser?.id == event.user?.id) { - _logger.info( - '[AppBloc] AppUserChanged triggered, but user ID is the same. ' - 'Skipping reload.', + // If there was a critical error during bootstrap (e.g., RemoteConfig fetch failed), + // we should immediately transition to criticalError state. + if (state.initialRemoteConfigError != null) { + _logger.severe( + '[AppBloc] Initial RemoteConfig fetch failed during bootstrap. ' + 'Transitioning to critical error.', ); + emit(state.copyWith(status: AppLifeCycleStatus.criticalError)); return; } - // --- Handle Unauthenticated State (User is null) --- - if (event.user == null) { - _logger.info( - '[AppBloc] User is null. Transitioning to unauthenticated state.', + // If RemoteConfig is null at this point, it's an unexpected error. + if (state.remoteConfig == null) { + _logger.severe( + '[AppBloc] RemoteConfig is null after bootstrap, but no error was reported. ' + 'Transitioning to critical error.', ); emit( state.copyWith( - status: AppLifeCycleStatus.unauthenticated, - user: null, + status: AppLifeCycleStatus.criticalError, + initialRemoteConfigError: + const UnknownException('RemoteConfig is null after bootstrap.'), ), ); return; } - // --- Handle Authenticated/Anonymous State (User is present) --- - final newUser = event.user!; - _logger.info( - '[AppBloc] User changed to ${newUser.id} (${newUser.appRole}). ' - 'Beginning data fetch sequence.', - ); - - // Immediately emit the new user. - emit(state.copyWith(user: newUser)); + // Evaluate global app status from RemoteConfig first. + if (state.remoteConfig!.appStatus.isUnderMaintenance) { + _logger.info( + '[AppBloc] App is under maintenance. Transitioning to maintenance state.', + ); + emit(state.copyWith(status: AppLifeCycleStatus.underMaintenance)); + return; + } - // In demo mode, ensure user-specific data is initialized. - if (_environment == local_config.AppEnvironment.demo && - demoDataInitializerService != null) { - try { - _logger.info('Demo mode: Initializing data for user ${newUser.id}.'); - await demoDataInitializerService!.initializeUserSpecificData(newUser); - _logger.info( - 'Demo mode: Data initialization complete for ${newUser.id}.', - ); - } catch (e, s) { - _logger.severe('ERROR: Failed to initialize demo user data.', e, s); - } + if (state.remoteConfig!.appStatus.isLatestVersionOnly) { + // TODO(fulleni): Compare with actual app version. + _logger.info( + '[AppBloc] App update required. Transitioning to updateRequired state.', + ); + emit(state.copyWith(status: AppLifeCycleStatus.updateRequired)); + return; } - // Handle data migration if an anonymous user signs in. - if (oldUser != null && - oldUser.appRole == AppUserRole.guestUser && - newUser.appRole == AppUserRole.standardUser) { + // If we reach here, the app is not under maintenance or requires update. + // Proceed to load user-specific data. + emit(state.copyWith(status: AppLifeCycleStatus.loadingUserData)); + + final currentUser = event.initialUser; + + if (currentUser == null) { _logger.info( - 'Anonymous user ${oldUser.id} transitioned to authenticated user ' - '${newUser.id}. Attempting data migration.', + '[AppBloc] No initial user. Transitioning to unauthenticated state.', ); - if (demoDataMigrationService != null && - _environment == local_config.AppEnvironment.demo) { - await demoDataMigrationService!.migrateAnonymousData( - oldUserId: oldUser.id, - newUserId: newUser.id, - ); - _logger.info('Demo mode: Data migration completed for ${newUser.id}.'); - } + emit(state.copyWith(status: AppLifeCycleStatus.unauthenticated)); + return; } - // --- Fetch Core Application Data --- + // User is present, proceed to fetch user-specific settings and preferences. + _logger.info( + '[AppBloc] Initial user found: ${currentUser.id} (${currentUser.appRole}). ' + 'Fetching user settings and preferences.', + ); + try { - UserAppSettings userAppSettings; - try { - // Attempt to fetch user settings from the backend. - userAppSettings = await _userAppSettingsRepository.read( - id: newUser.id, - userId: newUser.id, - ); - _logger.info( - '[AppBloc] UserAppSettings fetched successfully for user: ${newUser.id}', - ); - } on HttpException catch (e) { - // If any HttpException occurs during read or create, transition to critical error. - _logger.severe( - '[AppBloc] Failed to fetch UserAppSettings for user ' - '${newUser.id}: ${e.runtimeType} - ${e.message}', - ); - emit( - state.copyWith( - status: AppLifeCycleStatus.criticalError, - initialRemoteConfigError: e, - ), - ); - return; // Critical error, stop further processing. - } catch (e, s) { - // Catch any other unexpected errors during read or create. - _logger.severe( - '[AppBloc] Unexpected error during UserAppSettings fetch/create for user ' - '${newUser.id}.', - e, - s, - ); - emit( - state.copyWith( - status: AppLifeCycleStatus.criticalError, - initialRemoteConfigError: UnknownException(e.toString()), - ), - ); - return; // Critical error, stop further processing. - } + // Fetch UserAppSettings + final userAppSettings = await _userAppSettingsRepository.read( + id: currentUser.id, + userId: currentUser.id, + ); + _logger.info( + '[AppBloc] UserAppSettings fetched successfully for user: ${currentUser.id}', + ); + + // Fetch UserContentPreferences + final userContentPreferences = + await _userContentPreferencesRepository.read( + id: currentUser.id, + userId: currentUser.id, + ); + _logger.info( + '[AppBloc] UserContentPreferences fetched successfully for user: ${currentUser.id}', + ); // Map loaded settings to the AppState. final newThemeMode = _mapAppBaseTheme( @@ -284,108 +236,118 @@ class AppBloc extends Bloc { ); final newLocale = Locale(userAppSettings.language.code); - // --- CRITICAL STATUS EVALUATION (using already available remoteConfig) --- - // The remoteConfig is already in the state from the initial bootstrap. - // We use the existing state.remoteConfig to perform these checks. - final remoteConfig = state.remoteConfig; - - // If remoteConfig is null at this point, it means the initial fetch - // in bootstrap failed, and we should transition to a critical error state. - if (remoteConfig == null) { - emit( - state.copyWith( - status: AppLifeCycleStatus.criticalError, - initialRemoteConfigError: state.initialRemoteConfigError ?? - const UnknownException('RemoteConfig is null after user change.'), - settings: userAppSettings, - themeMode: newThemeMode, - flexScheme: newFlexScheme, - fontFamily: newFontFamily, - appTextScaleFactor: newAppTextScaleFactor, - locale: newLocale, - ), - ); - return; - } - - if (remoteConfig.appStatus.isUnderMaintenance) { - emit( - state.copyWith( - status: AppLifeCycleStatus.underMaintenance, - // remoteConfig: remoteConfig, // Already in state, no need to re-assign - settings: userAppSettings, - themeMode: newThemeMode, - flexScheme: newFlexScheme, - fontFamily: newFontFamily, - appTextScaleFactor: newAppTextScaleFactor, - locale: newLocale, - ), - ); - return; - } - - if (remoteConfig.appStatus.isLatestVersionOnly) { - // TODO(fulleni): Compare with actual app version. - emit( - state.copyWith( - status: AppLifeCycleStatus.updateRequired, - // remoteConfig: remoteConfig, // Already in state, no need to re-assign - settings: userAppSettings, - themeMode: newThemeMode, - flexScheme: newFlexScheme, - fontFamily: newFontFamily, - appTextScaleFactor: newAppTextScaleFactor, - locale: newLocale, - ), - ); - return; - } - - // --- Final State Transition --- - // If no critical status, transition to the final stable state. - final finalStatus = newUser.appRole == AppUserRole.standardUser + final finalStatus = currentUser.appRole == AppUserRole.standardUser ? AppLifeCycleStatus.authenticated : AppLifeCycleStatus.anonymous; emit( state.copyWith( status: finalStatus, - // remoteConfig: remoteConfig, // Already in state, no need to re-assign + user: currentUser, settings: userAppSettings, + userContentPreferences: userContentPreferences, // Store userContentPreferences themeMode: newThemeMode, flexScheme: newFlexScheme, fontFamily: newFontFamily, appTextScaleFactor: newAppTextScaleFactor, locale: newLocale, + initialUserPreferencesError: null, ), ); } on HttpException catch (e) { _logger.severe( - '[AppBloc] Failed to fetch or create UserAppSettings (HttpException) for user ' - '${newUser.id}: ${e.runtimeType} - ${e.message}', + '[AppBloc] Failed to fetch user settings or preferences (HttpException) for user ' + '${currentUser.id}: ${e.runtimeType} - ${e.message}', ); emit( state.copyWith( status: AppLifeCycleStatus.criticalError, - initialRemoteConfigError: e, + initialUserPreferencesError: e, ), ); } catch (e, s) { _logger.severe( - '[AppBloc] Unexpected error during UserAppSettings fetch/create for user ' - '${newUser.id}.', + '[AppBloc] Unexpected error during user settings/preferences fetch for user ' + '${currentUser.id}.', e, s, ); emit( state.copyWith( status: AppLifeCycleStatus.criticalError, - initialRemoteConfigError: UnknownException(e.toString()), + initialUserPreferencesError: UnknownException(e.toString()), ), ); } } + /// Handles all logic related to user authentication state changes. + /// + /// This method is now simplified to only update the user and status based + /// on the authentication stream, and handle data migration. + Future _onAppUserChanged( + AppUserChanged event, + Emitter emit, + ) async { + final oldUser = state.user; + final newUser = event.user; + + // Optimization: Prevent redundant reloads if the user ID hasn't changed. + if (oldUser?.id == newUser?.id) { + _logger.info( + '[AppBloc] AppUserChanged triggered, but user ID is the same. ' + 'Skipping reload.', + ); + return; + } + + // Update the user in the state. + emit(state.copyWith(user: newUser)); + + // Determine the new status based on the user. + final newStatus = newUser == null + ? AppLifeCycleStatus.unauthenticated + : (newUser.appRole == AppUserRole.standardUser + ? AppLifeCycleStatus.authenticated + : AppLifeCycleStatus.anonymous); + + emit(state.copyWith(status: newStatus)); + + // In demo mode, ensure user-specific data is initialized. + if (_environment == local_config.AppEnvironment.demo && + demoDataInitializerService != null && + newUser != null) { + try { + _logger.info('Demo mode: Initializing data for user ${newUser.id}.'); + await demoDataInitializerService!.initializeUserSpecificData(newUser); + _logger.info( + 'Demo mode: Data initialization complete for ${newUser.id}.', + ); + } catch (e, s) { + _logger.severe('ERROR: Failed to initialize demo user data.', e, s); + } + } + + // Handle data migration if an anonymous user signs in. + if (oldUser != null && + oldUser.appRole == AppUserRole.guestUser && + newUser != null && + newUser.appRole == AppUserRole.standardUser) { + _logger.info( + 'Anonymous user ${oldUser.id} transitioned to authenticated user ' + '${newUser.id}. Attempting data migration.', + ); + if (demoDataMigrationService != null && + _environment == local_config.AppEnvironment.demo) { + await demoDataMigrationService!.migrateAnonymousData( + oldUserId: oldUser.id, + newUserId: newUser.id, + ); + _logger.info('Demo mode: Data migration completed for ${newUser.id}.'); + } + } + } + /// Handles refreshing/loading app settings (theme, font). Future _onAppSettingsRefreshed( AppSettingsRefreshed event, @@ -402,6 +364,13 @@ class AppBloc extends Bloc { userId: state.user!.id, ); + // Also fetch UserContentPreferences when settings are refreshed + final userContentPreferences = + await _userContentPreferencesRepository.read( + id: state.user!.id, + userId: state.user!.id, + ); + final newThemeMode = _mapAppBaseTheme( userAppSettings.displaySettings.baseTheme, ); @@ -431,25 +400,30 @@ class AppBloc extends Bloc { appTextScaleFactor: newAppTextScaleFactor, fontFamily: newFontFamily, settings: userAppSettings, + userContentPreferences: userContentPreferences, // Store userContentPreferences locale: newLocale, ), ); } on HttpException catch (e) { _logger.severe( - 'Error loading user app settings in AppBloc (HttpException): $e', + 'Error loading user app settings or preferences in AppBloc (HttpException): $e', ); emit( state.copyWith( status: AppLifeCycleStatus.criticalError, - initialRemoteConfigError: e, + initialUserPreferencesError: e, // Use the new error field ), ); } catch (e, s) { - _logger.severe('Error loading user app settings in AppBloc.', e, s); + _logger.severe( + 'Error loading user app settings or preferences in AppBloc.', + e, + s, + ); emit( state.copyWith( status: AppLifeCycleStatus.criticalError, - initialRemoteConfigError: UnknownException(e.toString()), + initialUserPreferencesError: UnknownException(e.toString()), // Use the new error field ), ); } @@ -681,11 +655,12 @@ class AppBloc extends Bloc { return; } + // Only show critical error if it's not a background check. if (!event.isBackgroundCheck) { _logger.info( - '[AppBloc] Initial config fetch. Setting status to criticalError if failed.', + '[AppBloc] Initial config fetch. Setting status to loadingUserData if failed.', ); - emit(state.copyWith(status: AppLifeCycleStatus.criticalError)); + emit(state.copyWith(status: AppLifeCycleStatus.loadingUserData)); } else { _logger.info('[AppBloc] Background config fetch. Proceeding silently.'); } From 2bffd3eacb2a9fb7089d521f76db8f1887fe7ec3 Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 18:22:01 +0100 Subject: [PATCH 13/94] feat(app): implement user preferences loading and error handling - Add userContentPreferencesRepository to AppBloc - Dispatch AppStarted event with initial user - Implement loading state for user data - Add error handling for user preferences loading - Update CriticalErrorPage to handle different error states --- lib/app/view/app.dart | 56 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 192a5f6f..7373800e 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -123,6 +123,8 @@ class App extends StatelessWidget { authenticationRepository: context.read(), userAppSettingsRepository: context.read>(), + userContentPreferencesRepository: + context.read>(), appConfigRepository: context.read>(), userRepository: context.read>(), environment: _environment, @@ -134,7 +136,7 @@ class App extends StatelessWidget { _initialRemoteConfig, // Pass initialRemoteConfig initialRemoteConfigError: _initialRemoteConfigError, // Pass initialRemoteConfigError - ), + )..add(AppStarted(initialUser: initialUser)), // Dispatch AppStarted event ), BlocProvider( create: (context) => AuthenticationBloc( @@ -261,6 +263,7 @@ class _AppViewState extends State<_AppView> { @override Widget build(BuildContext context) { + // Wrap the part of the tree that needs to react to AppBloc state changes // with a BlocListener and a BlocBuilder. return BlocListener( @@ -280,6 +283,37 @@ class _AppViewState extends State<_AppView> { // to fixing the original race conditions and BuildContext instability. child: BlocBuilder( builder: (context, state) { + // --- Loading State --- + if (state.status == AppLifeCycleStatus.loadingUserData) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: lightTheme( + scheme: FlexScheme.material, + appTextScaleFactor: AppTextScaleFactor.medium, + appFontWeight: AppFontWeight.regular, + fontFamily: null, + ), + darkTheme: darkTheme( + scheme: FlexScheme.material, + appTextScaleFactor: AppTextScaleFactor.medium, + appFontWeight: AppFontWeight.regular, + fontFamily: null, + ), + themeMode: state.themeMode, + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + ...UiKitLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: state.locale, + home: const LoadingStateWidget( + icon: Icons.sync, + headline: 'Loading User Data', // Placeholder + subheadline: 'Fetching your settings and preferences...', // Placeholder + ), + ); + } + // --- Full-Screen Status Pages --- // The following states represent critical, app-wide conditions that // must be handled before the main router and UI are displayed. @@ -310,11 +344,25 @@ class _AppViewState extends State<_AppView> { locale: state.locale, home: CriticalErrorPage( exception: state.initialRemoteConfigError ?? + state.initialUserPreferencesError ?? const UnknownException('An unknown critical error occurred.'), onRetry: () { - context - .read() - .add(const AppConfigFetchRequested(isBackgroundCheck: false)); + // If remote config failed, retry remote config. + // If user preferences failed, retry AppStarted. + if (state.initialRemoteConfigError != null) { + context.read().add( + const AppConfigFetchRequested(isBackgroundCheck: false), + ); + } else if (state.initialUserPreferencesError != null) { + context.read().add( + AppStarted(initialUser: state.user), + ); + } else { + // Fallback for unknown critical error + context.read().add( + AppStarted(initialUser: state.user), + ); + } }, ), ); From 0a78bbd023d5bdddcf98116ff3eb6ecb5eaf9198 Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 18:22:07 +0100 Subject: [PATCH 14/94] refactor(settings): rethrow exceptions for centralized error handling - Remove duplicate error handling logic - Re-throw HttpException and other exceptions to be handled by AppBloc - Simplify settings loading process --- lib/settings/bloc/settings_bloc.dart | 40 +++------------------------- 1 file changed, 3 insertions(+), 37 deletions(-) diff --git a/lib/settings/bloc/settings_bloc.dart b/lib/settings/bloc/settings_bloc.dart index 2d40b820..bf3251cf 100644 --- a/lib/settings/bloc/settings_bloc.dart +++ b/lib/settings/bloc/settings_bloc.dart @@ -99,44 +99,10 @@ class SettingsBloc extends Bloc { userAppSettings: appSettings, ), ); - } on NotFoundException { - // Settings not found for the user, create and persist defaults - final defaultLanguage = languagesFixturesData.firstWhere( - (l) => l.code == 'en', - orElse: () => throw StateError( - 'Default language "en" not found in language fixtures.', - ), - ); - - final defaultSettings = UserAppSettings( - id: event.userId, - displaySettings: const DisplaySettings( - baseTheme: AppBaseTheme.system, - accentTheme: AppAccentTheme.defaultBlue, - fontFamily: 'SystemDefault', - textScaleFactor: AppTextScaleFactor.medium, - fontWeight: AppFontWeight.regular, - ), - language: defaultLanguage, - feedPreferences: const FeedDisplayPreferences( - headlineDensity: HeadlineDensity.standard, - headlineImageStyle: HeadlineImageStyle.largeThumbnail, - showSourceInHeadlineFeed: true, - showPublishDateInHeadlineFeed: true, - ), - ); - emit( - state.copyWith( - status: SettingsStatus.success, - userAppSettings: defaultSettings, - ), - ); - // Persist these default settings - await _persistSettings(defaultSettings, emit); - } on HttpException catch (e) { - emit(state.copyWith(status: SettingsStatus.failure, error: e)); + } on HttpException { + rethrow; // Re-throw to AppBloc for centralized error handling } catch (e) { - emit(state.copyWith(status: SettingsStatus.failure, error: e)); + rethrow; // Re-throw to AppBloc for centralized error handling } } From 3a89338edf50805deab646ab6646374c502239dd Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 19:45:33 +0100 Subject: [PATCH 15/94] refactor(app): improve AppBloc initialization and user data loading - Restructure AppBloc constructor for better readability - Implement AppUserDataLoaded event to signal successful user data load - Add null checks for settings in theme/font-related event handlers - Update initial AppState to reflect nullable settings - Improve code formatting and consistency --- lib/app/bloc/app_bloc.dart | 202 ++++++++++++++++++++++--------------- 1 file changed, 121 insertions(+), 81 deletions(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 8fc6065e..c75724c2 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -33,7 +33,7 @@ class AppBloc extends Bloc { required AuthRepository authenticationRepository, required DataRepository userAppSettingsRepository, required DataRepository - userContentPreferencesRepository, + userContentPreferencesRepository, required DataRepository appConfigRepository, required DataRepository userRepository, required local_config.AppEnvironment environment, @@ -43,54 +43,37 @@ class AppBloc extends Bloc { this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, - }) : _authenticationRepository = authenticationRepository, - _userAppSettingsRepository = userAppSettingsRepository, - _userContentPreferencesRepository = userContentPreferencesRepository, - _appConfigRepository = appConfigRepository, - _userRepository = userRepository, - _environment = environment, - _navigatorKey = navigatorKey, - _logger = Logger('AppBloc'), - super( - // Initial state of the app. The status is set to loadingUserData - // as the AppBloc will now handle fetching user-specific data. - AppState( - status: AppLifeCycleStatus.loadingUserData, - settings: UserAppSettings( - id: 'default', // This will be replaced by actual user settings - displaySettings: const DisplaySettings( - baseTheme: AppBaseTheme.system, - accentTheme: AppAccentTheme.defaultBlue, - fontFamily: 'SystemDefault', - textScaleFactor: AppTextScaleFactor.medium, - fontWeight: AppFontWeight.regular, - ), - language: languagesFixturesData.firstWhere( - (l) => l.code == 'en', - orElse: () => throw StateError( - 'Default language "en" not found in language fixtures.', - ), - ), - feedPreferences: const FeedDisplayPreferences( - headlineDensity: HeadlineDensity.standard, - headlineImageStyle: HeadlineImageStyle.largeThumbnail, - showSourceInHeadlineFeed: true, - showPublishDateInHeadlineFeed: true, - ), - ), - selectedBottomNavigationIndex: 0, - remoteConfig: initialRemoteConfig, // Use the pre-fetched config - initialRemoteConfigError: - initialRemoteConfigError, // Store any initial config error - environment: environment, - user: initialUser, // Set initial user if available - themeMode: _mapAppBaseTheme(AppBaseTheme.system), // Default to system theme - flexScheme: _mapAppAccentTheme(AppAccentTheme.defaultBlue), - fontFamily: _mapFontFamily('SystemDefault'), - appTextScaleFactor: AppTextScaleFactor.medium, - locale: const Locale('en'), // Default to English - ), - ) { + }) : _authenticationRepository = authenticationRepository, + _userAppSettingsRepository = userAppSettingsRepository, + _userContentPreferencesRepository = userContentPreferencesRepository, + _appConfigRepository = appConfigRepository, + _userRepository = userRepository, + _environment = environment, + _navigatorKey = navigatorKey, + _logger = Logger('AppBloc'), + super( + // Initial state of the app. The status is set to loadingUserData + // as the AppBloc will now handle fetching user-specific data. + // UserAppSettings and UserContentPreferences are initially null + // and will be fetched asynchronously. + AppState( + status: AppLifeCycleStatus.loadingUserData, + // settings is now nullable and will be fetched. + settings: null, + selectedBottomNavigationIndex: 0, + remoteConfig: initialRemoteConfig, // Use the pre-fetched config + initialRemoteConfigError: + initialRemoteConfigError, // Store any initial config error + environment: environment, + user: initialUser, // Set initial user if available + // Default theme settings until user settings are loaded + themeMode: _mapAppBaseTheme(AppBaseTheme.system), + flexScheme: _mapAppAccentTheme(AppAccentTheme.defaultBlue), + fontFamily: _mapFontFamily('SystemDefault'), + appTextScaleFactor: AppTextScaleFactor.medium, + locale: const Locale('en'), // Default to English + ), + ) { // Register event handlers for various app-level events. on(_onAppStarted); // New event handler on(_onAppUserChanged); @@ -103,6 +86,7 @@ class AppBloc extends Bloc { on(_onFontFamilyChanged); on(_onAppTextScaleFactorChanged); on(_onAppFontWeightChanged); + on(_onAppUserDataLoaded); // New event handler // Subscribe to the authentication repository's authStateChanges stream. // This stream is the single source of truth for the user's auth state @@ -114,7 +98,8 @@ class AppBloc extends Bloc { final AuthRepository _authenticationRepository; final DataRepository _userAppSettingsRepository; - final DataRepository _userContentPreferencesRepository; + final DataRepository + _userContentPreferencesRepository; final DataRepository _appConfigRepository; final DataRepository _userRepository; final local_config.AppEnvironment _environment; @@ -130,10 +115,7 @@ class AppBloc extends Bloc { /// Handles the [AppStarted] event, orchestrating the initial loading of /// user-specific data and evaluating the overall app status. - Future _onAppStarted( - AppStarted event, - Emitter emit, - ) async { + Future _onAppStarted(AppStarted event, Emitter emit) async { _logger.info('[AppBloc] AppStarted event received. Starting data load.'); // If there was a critical error during bootstrap (e.g., RemoteConfig fetch failed), @@ -156,8 +138,9 @@ class AppBloc extends Bloc { emit( state.copyWith( status: AppLifeCycleStatus.criticalError, - initialRemoteConfigError: - const UnknownException('RemoteConfig is null after bootstrap.'), + initialRemoteConfigError: const UnknownException( + 'RemoteConfig is null after bootstrap.', + ), ), ); return; @@ -212,11 +195,8 @@ class AppBloc extends Bloc { ); // Fetch UserContentPreferences - final userContentPreferences = - await _userContentPreferencesRepository.read( - id: currentUser.id, - userId: currentUser.id, - ); + final userContentPreferences = await _userContentPreferencesRepository + .read(id: currentUser.id, userId: currentUser.id); _logger.info( '[AppBloc] UserContentPreferences fetched successfully for user: ${currentUser.id}', ); @@ -245,7 +225,8 @@ class AppBloc extends Bloc { status: finalStatus, user: currentUser, settings: userAppSettings, - userContentPreferences: userContentPreferences, // Store userContentPreferences + userContentPreferences: + userContentPreferences, // Store userContentPreferences themeMode: newThemeMode, flexScheme: newFlexScheme, fontFamily: newFontFamily, @@ -254,6 +235,8 @@ class AppBloc extends Bloc { initialUserPreferencesError: null, ), ); + // Dispatch event to signal that user settings are loaded + add(const AppUserDataLoaded()); } on HttpException catch (e) { _logger.severe( '[AppBloc] Failed to fetch user settings or preferences (HttpException) for user ' @@ -308,8 +291,8 @@ class AppBloc extends Bloc { final newStatus = newUser == null ? AppLifeCycleStatus.unauthenticated : (newUser.appRole == AppUserRole.standardUser - ? AppLifeCycleStatus.authenticated - : AppLifeCycleStatus.anonymous); + ? AppLifeCycleStatus.authenticated + : AppLifeCycleStatus.anonymous); emit(state.copyWith(status: newStatus)); @@ -365,11 +348,8 @@ class AppBloc extends Bloc { ); // Also fetch UserContentPreferences when settings are refreshed - final userContentPreferences = - await _userContentPreferencesRepository.read( - id: state.user!.id, - userId: state.user!.id, - ); + final userContentPreferences = await _userContentPreferencesRepository + .read(id: state.user!.id, userId: state.user!.id); final newThemeMode = _mapAppBaseTheme( userAppSettings.displaySettings.baseTheme, @@ -400,10 +380,14 @@ class AppBloc extends Bloc { appTextScaleFactor: newAppTextScaleFactor, fontFamily: newFontFamily, settings: userAppSettings, - userContentPreferences: userContentPreferences, // Store userContentPreferences + userContentPreferences: + userContentPreferences, // Store userContentPreferences locale: newLocale, + initialUserPreferencesError: null, ), ); + // Dispatch event to signal that user settings are loaded + add(const AppUserDataLoaded()); } on HttpException catch (e) { _logger.severe( 'Error loading user app settings or preferences in AppBloc (HttpException): $e', @@ -423,7 +407,9 @@ class AppBloc extends Bloc { emit( state.copyWith( status: AppLifeCycleStatus.criticalError, - initialUserPreferencesError: UnknownException(e.toString()), // Use the new error field + initialUserPreferencesError: UnknownException( + e.toString(), + ), // Use the new error field ), ); } @@ -439,8 +425,16 @@ class AppBloc extends Bloc { AppThemeModeChanged event, Emitter emit, ) async { - final updatedSettings = state.settings.copyWith( - displaySettings: state.settings.displaySettings.copyWith( + // Ensure settings are loaded before attempting to update. + if (state.settings == null) { + _logger.warning( + '[AppBloc] Skipping theme mode change: UserAppSettings not loaded.', + ); + return; + } + + final updatedSettings = state.settings!.copyWith( + displaySettings: state.settings!.displaySettings.copyWith( baseTheme: event.themeMode == ThemeMode.light ? AppBaseTheme.light : (event.themeMode == ThemeMode.dark @@ -470,8 +464,16 @@ class AppBloc extends Bloc { AppFlexSchemeChanged event, Emitter emit, ) async { - final updatedSettings = state.settings.copyWith( - displaySettings: state.settings.displaySettings.copyWith( + // Ensure settings are loaded before attempting to update. + if (state.settings == null) { + _logger.warning( + '[AppBloc] Skipping accent scheme change: UserAppSettings not loaded.', + ); + return; + } + + final updatedSettings = state.settings!.copyWith( + displaySettings: state.settings!.displaySettings.copyWith( accentTheme: event.flexScheme == FlexScheme.blue ? AppAccentTheme.defaultBlue : (event.flexScheme == FlexScheme.red @@ -505,8 +507,16 @@ class AppBloc extends Bloc { AppFontFamilyChanged event, Emitter emit, ) async { - final updatedSettings = state.settings.copyWith( - displaySettings: state.settings.displaySettings.copyWith( + // Ensure settings are loaded before attempting to update. + if (state.settings == null) { + _logger.warning( + '[AppBloc] Skipping font family change: UserAppSettings not loaded.', + ); + return; + } + + final updatedSettings = state.settings!.copyWith( + displaySettings: state.settings!.displaySettings.copyWith( fontFamily: event.fontFamily ?? 'SystemDefault', ), ); @@ -534,8 +544,16 @@ class AppBloc extends Bloc { AppTextScaleFactorChanged event, Emitter emit, ) async { - final updatedSettings = state.settings.copyWith( - displaySettings: state.settings.displaySettings.copyWith( + // Ensure settings are loaded before attempting to update. + if (state.settings == null) { + _logger.warning( + '[AppBloc] Skipping text scale factor change: UserAppSettings not loaded.', + ); + return; + } + + final updatedSettings = state.settings!.copyWith( + displaySettings: state.settings!.displaySettings.copyWith( textScaleFactor: event.appTextScaleFactor, ), ); @@ -568,8 +586,16 @@ class AppBloc extends Bloc { AppFontWeightChanged event, Emitter emit, ) async { - final updatedSettings = state.settings.copyWith( - displaySettings: state.settings.displaySettings.copyWith( + // Ensure settings are loaded before attempting to update. + if (state.settings == null) { + _logger.warning( + '[AppBloc] Skipping font weight change: UserAppSettings not loaded.', + ); + return; + } + + final updatedSettings = state.settings!.copyWith( + displaySettings: state.settings!.displaySettings.copyWith( fontWeight: event.fontWeight, ), ); @@ -781,4 +807,18 @@ class AppBloc extends Bloc { } } } + + /// Handles the [AppUserDataLoaded] event. + /// This event is dispatched when user-specific settings (UserAppSettings and + /// UserContentPreferences) have been successfully loaded and updated in the + /// AppBloc state. This handler currently does nothing, but it serves as a + /// placeholder for future logic that might need to react to this event. + Future _onAppUserDataLoaded( + AppUserDataLoaded event, + Emitter emit, + ) async { + _logger.info('[AppBloc] AppUserSettingsLoaded event received.'); + // This event is primarily for other BLoCs to listen to. + // AppBloc itself doesn't need to do anything further here. + } } From 22ca2f28e9a5e54d44bf7d566acbc0034eddd19e Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 19:45:58 +0100 Subject: [PATCH 16/94] feat(app): add AppUserDataLoaded event This event is dispatched when user-specific data (UserAppSettings and UserContentPreferences) have been successfully loaded and updated in the AppBloc state. --- lib/app/bloc/app_event.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart index 622e17ce..16892d23 100644 --- a/lib/app/bloc/app_event.dart +++ b/lib/app/bloc/app_event.dart @@ -106,3 +106,9 @@ class AppUserFeedDecoratorShown extends AppEvent { @override List get props => [userId, feedDecoratorType, isCompleted]; } + +/// Dispatched when user-specific data (UserAppSettings and UserContentPreferences) +/// have been successfully loaded and updated in the AppBloc state. +class AppUserDataLoaded extends AppEvent { + const AppUserDataLoaded(); +} From ccfa8893a659f732848c150389543d5388e867e2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 19:46:10 +0100 Subject: [PATCH 17/94] refactor(app): make `settings` nullable in `AppState` - Update `AppState` class to make `settings` nullable - Adjust the order of parameters in the constructor - Add documentation comment explaining that `settings` is null until successfully fetched from the backend --- lib/app/bloc/app_state.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index 3ba7a029..82db390f 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -39,13 +39,13 @@ class AppState extends Equatable { /// {@macro app_state} const AppState({ required this.status, - required this.settings, required this.environment, this.user, this.remoteConfig, this.initialRemoteConfigError, this.initialUserPreferencesError, this.userContentPreferences, + this.settings, this.themeMode = ThemeMode.system, this.flexScheme = FlexScheme.blue, this.fontFamily, @@ -61,7 +61,8 @@ class AppState extends Equatable { final User? user; /// The user's application settings, including display preferences. - final UserAppSettings settings; + /// This is null until successfully fetched from the backend. + final UserAppSettings? settings; /// The remote configuration fetched from the backend. final RemoteConfig? remoteConfig; From b97769666d40e0d1e6fa3bc0123e2bf204e45be7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Mon, 15 Sep 2025 19:47:05 +0100 Subject: [PATCH 18/94] refactor(app): improve app initialization and loading state handling - Remove unnecessary `super.key` parameter - Enhance loading state UI for user data initialization - Refine app theme configuration with null safety - Improve code readability and structure --- lib/app/view/app.dart | 96 ++++++++++++++++++++++--------------------- 1 file changed, 50 insertions(+), 46 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 7373800e..acf83bdb 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -52,7 +52,6 @@ class App extends StatelessWidget { this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, - super.key, }) : _authenticationRepository = authenticationRepository, _headlinesRepository = headlinesRepository, _topicsRepository = topicsRepository, @@ -263,7 +262,6 @@ class _AppViewState extends State<_AppView> { @override Widget build(BuildContext context) { - // Wrap the part of the tree that needs to react to AppBloc state changes // with a BlocListener and a BlocBuilder. return BlocListener( @@ -283,37 +281,6 @@ class _AppViewState extends State<_AppView> { // to fixing the original race conditions and BuildContext instability. child: BlocBuilder( builder: (context, state) { - // --- Loading State --- - if (state.status == AppLifeCycleStatus.loadingUserData) { - return MaterialApp( - debugShowCheckedModeBanner: false, - theme: lightTheme( - scheme: FlexScheme.material, - appTextScaleFactor: AppTextScaleFactor.medium, - appFontWeight: AppFontWeight.regular, - fontFamily: null, - ), - darkTheme: darkTheme( - scheme: FlexScheme.material, - appTextScaleFactor: AppTextScaleFactor.medium, - appFontWeight: AppFontWeight.regular, - fontFamily: null, - ), - themeMode: state.themeMode, - localizationsDelegates: const [ - ...AppLocalizations.localizationsDelegates, - ...UiKitLocalizations.localizationsDelegates, - ], - supportedLocales: AppLocalizations.supportedLocales, - locale: state.locale, - home: const LoadingStateWidget( - icon: Icons.sync, - headline: 'Loading User Data', // Placeholder - subheadline: 'Fetching your settings and preferences...', // Placeholder - ), - ); - } - // --- Full-Screen Status Pages --- // The following states represent critical, app-wide conditions that // must be handled before the main router and UI are displayed. @@ -435,21 +402,58 @@ class _AppViewState extends State<_AppView> { ); } + // --- Loading User Data State --- + // If the app is not in a critical status but user settings or preferences + // are still null, display a loading screen. This ensures the main UI + // is only built when all necessary user-specific data is available. + if (state.settings == null || state.userContentPreferences == null) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: lightTheme( + scheme: FlexScheme.material, + appTextScaleFactor: AppTextScaleFactor.medium, + appFontWeight: AppFontWeight.regular, + fontFamily: null, + ), + darkTheme: darkTheme( + scheme: FlexScheme.material, + appTextScaleFactor: AppTextScaleFactor.medium, + appFontWeight: AppFontWeight.regular, + fontFamily: null, + ), + themeMode: state.themeMode, + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + ...UiKitLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: state.locale, + home: LoadingStateWidget( + icon: Icons.sync, + headline: AppLocalizations.of(context).settingsLoadingHeadline, + subheadline: + AppLocalizations.of(context).settingsLoadingSubheadline, + ), + ); + } + // --- Main Application UI --- - // If none of the critical states above are met, the app is ready - // to display its main UI. We build the MaterialApp.router here. + // If none of the critical states above are met, and user settings + // are loaded, the app is ready to display its main UI. + // We build the MaterialApp.router here. // This is the single, STABLE root widget for the entire main app. // // WHY IS THIS SO IMPORTANT? // Because this widget is now built conditionally inside a single // BlocBuilder, it is created only ONCE when the app enters a - // "running" state (e.g., authenticated, anonymous). It is no longer - // destroyed and rebuilt during startup, which was the root cause of - // the `BuildContext` instability and the `l10n` crashes. + // "running" state (e.g., authenticated, anonymous) with all + // necessary data. It is no longer destroyed and rebuilt during + // startup, which was the root cause of the `BuildContext` instability + // and the `l10n` crashes. // // THEME CONFIGURATION: - // Unlike the status pages, this MaterialApp is themed using the full, - // detailed settings loaded into the AppState (e.g., `state.flexScheme`, + // This MaterialApp is themed using the full, detailed settings + // loaded into the AppState (e.g., `state.flexScheme`, // `state.settings.displaySettings...`), providing the complete, // personalized user experience. return MaterialApp.router( @@ -458,16 +462,16 @@ class _AppViewState extends State<_AppView> { theme: lightTheme( scheme: state.flexScheme, appTextScaleFactor: - state.settings.displaySettings.textScaleFactor, - appFontWeight: state.settings.displaySettings.fontWeight, - fontFamily: state.settings.displaySettings.fontFamily, + state.settings!.displaySettings.textScaleFactor, + appFontWeight: state.settings!.displaySettings.fontWeight, + fontFamily: state.settings!.displaySettings.fontFamily, ), darkTheme: darkTheme( scheme: state.flexScheme, appTextScaleFactor: - state.settings.displaySettings.textScaleFactor, - appFontWeight: state.settings.displaySettings.fontWeight, - fontFamily: state.settings.displaySettings.fontFamily, + state.settings!.displaySettings.textScaleFactor, + appFontWeight: state.settings!.displaySettings.fontWeight, + fontFamily: state.settings!.displaySettings.fontFamily, ), routerConfig: _router, locale: state.locale, From 49e275bd9ee0eaa4aacfe26afa8a9cf3c06cb503 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 07:20:51 +0100 Subject: [PATCH 19/94] refactor(app): derive theme and display settings from UserAppSettings - Remove direct theme and font properties from AppState - Implement getters for themeMode, flexScheme, fontFamily, appTextScaleFactor, and appFontWeight - Update documentation and prop list to reflect changes - Adjust copyWith method to remove direct theme and font parameters --- lib/app/bloc/app_state.dart | 98 +++++++++++++++++++++++-------------- 1 file changed, 60 insertions(+), 38 deletions(-) diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index 82db390f..332ee55b 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -33,7 +33,8 @@ enum AppLifeCycleStatus { /// Represents the overall state of the application. /// /// This state includes authentication status, user settings, remote -/// configuration, and UI-related preferences. +/// configuration, and UI-related preferences. It acts as the single source +/// of truth for global application state. /// {@endtemplate} class AppState extends Equatable { /// {@macro app_state} @@ -46,56 +47,92 @@ class AppState extends Equatable { this.initialUserPreferencesError, this.userContentPreferences, this.settings, - this.themeMode = ThemeMode.system, - this.flexScheme = FlexScheme.blue, - this.fontFamily, - this.appTextScaleFactor = AppTextScaleFactor.medium, this.selectedBottomNavigationIndex = 0, - this.locale, }); - /// The current status of the application. + /// The current status of the application, indicating its lifecycle stage. final AppLifeCycleStatus status; /// The currently authenticated or anonymous user. + /// Null if no user is logged in or recognized. final User? user; - /// The user's application settings, including display preferences. + /// The user's application settings, including display preferences and language. /// This is null until successfully fetched from the backend. final UserAppSettings? settings; /// The remote configuration fetched from the backend. + /// Contains global settings like maintenance mode, update requirements, and ad configurations. final RemoteConfig? remoteConfig; /// An error that occurred during the initial remote config fetch. + /// If not null, indicates a critical issue preventing app startup. final HttpException? initialRemoteConfigError; /// An error that occurred during the initial user preferences fetch. + /// If not null, indicates a critical issue preventing app startup. final HttpException? initialUserPreferencesError; - /// The user's content preferences, including followed countries, sources, topics, and saved headlines. + /// The user's content preferences, including followed countries, sources, + /// topics, and saved headlines. + /// This is null until successfully fetched from the backend. final UserContentPreferences? userContentPreferences; - /// The current theme mode (light, dark, or system). - final ThemeMode themeMode; + /// The currently selected index for bottom navigation. + final int selectedBottomNavigationIndex; - /// The current FlexColorScheme scheme for accent colors. - final FlexScheme flexScheme; + /// The current application environment (e.g., demo, development, production). + final local_config.AppEnvironment environment; - /// The currently selected font family. - final String? fontFamily; + /// The current theme mode (light, dark, or system), derived from [settings]. + /// Defaults to [ThemeMode.system] if [settings] are not yet loaded. + ThemeMode get themeMode { + return settings?.displaySettings.baseTheme == AppBaseTheme.light + ? ThemeMode.light + : (settings?.displaySettings.baseTheme == AppBaseTheme.dark + ? ThemeMode.dark + : ThemeMode.system); + } - /// The current text scale factor. - final AppTextScaleFactor appTextScaleFactor; + /// The current FlexColorScheme scheme for accent colors, derived from [settings]. + /// Defaults to [FlexScheme.blue] if [settings] are not yet loaded. + FlexScheme get flexScheme { + switch (settings?.displaySettings.accentTheme) { + case AppAccentTheme.newsRed: + return FlexScheme.red; + case AppAccentTheme.graphiteGray: + return FlexScheme.material; + case AppAccentTheme.defaultBlue: + case null: + return FlexScheme.blue; + } + } - /// The currently selected index for bottom navigation. - final int selectedBottomNavigationIndex; + /// The currently selected font family, derived from [settings]. + /// Returns null if 'SystemDefault' is selected or if [settings] are not yet loaded. + String? get fontFamily { + final family = settings?.displaySettings.fontFamily; + return family == 'SystemDefault' ? null : family; + } - /// The current application environment. - final local_config.AppEnvironment environment; + /// The current text scale factor, derived from [settings]. + /// Defaults to [AppTextScaleFactor.medium] if [settings] are not yet loaded. + AppTextScaleFactor get appTextScaleFactor { + return settings?.displaySettings.textScaleFactor ?? + AppTextScaleFactor.medium; + } - /// The currently selected locale for localization. - final Locale? locale; + /// The current font weight, derived from [settings]. + /// Defaults to [AppFontWeight.regular] if [settings] are not yet loaded. + AppFontWeight get appFontWeight { + return settings?.displaySettings.fontWeight ?? AppFontWeight.regular; + } + + /// The currently selected locale for localization, derived from [settings]. + /// Defaults to English ('en') if [settings] are not yet loaded. + Locale get locale { + return Locale(settings?.language.code ?? 'en'); + } @override List get props => [ @@ -106,13 +143,8 @@ class AppState extends Equatable { initialRemoteConfigError, initialUserPreferencesError, userContentPreferences, - themeMode, - flexScheme, - fontFamily, - appTextScaleFactor, selectedBottomNavigationIndex, environment, - locale, ]; /// Creates a copy of this [AppState] with the given fields replaced with @@ -126,13 +158,8 @@ class AppState extends Equatable { HttpException? initialRemoteConfigError, HttpException? initialUserPreferencesError, UserContentPreferences? userContentPreferences, - ThemeMode? themeMode, - FlexScheme? flexScheme, - String? fontFamily, - AppTextScaleFactor? appTextScaleFactor, int? selectedBottomNavigationIndex, local_config.AppEnvironment? environment, - Locale? locale, }) { return AppState( status: status ?? this.status, @@ -145,14 +172,9 @@ class AppState extends Equatable { initialUserPreferencesError ?? this.initialUserPreferencesError, userContentPreferences: userContentPreferences ?? this.userContentPreferences, - themeMode: themeMode ?? this.themeMode, - flexScheme: flexScheme ?? this.flexScheme, - fontFamily: fontFamily ?? this.fontFamily, - appTextScaleFactor: appTextScaleFactor ?? this.appTextScaleFactor, selectedBottomNavigationIndex: selectedBottomNavigationIndex ?? this.selectedBottomNavigationIndex, environment: environment ?? this.environment, - locale: locale ?? this.locale, ); } } From 9720106ee77779a02c94fed16b4d860710157104 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 07:21:11 +0100 Subject: [PATCH 20/94] refactor(app): update AppEvent hierarchy and add new events - Add abstract base class documentation for AppEvent - Enhance existing AppEvent subclasses with additional comments - Introduce new AppSettingsChanged event for user settings updates - Replace AppConfigFetchRequested with AppPeriodicConfigFetchRequested for clarity - Remove theme-related events (AppThemeModeChanged, AppFlexSchemeChanged, etc.) - Update AppUserFeedDecoratorShown event documentation --- lib/app/bloc/app_event.dart | 95 ++++++++++++++++++++----------------- 1 file changed, 52 insertions(+), 43 deletions(-) diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart index 16892d23..ae675881 100644 --- a/lib/app/bloc/app_event.dart +++ b/lib/app/bloc/app_event.dart @@ -1,5 +1,8 @@ part of 'app_bloc.dart'; +/// Abstract base class for all events in the [AppBloc]. +/// +/// All concrete app events must extend this class. abstract class AppEvent extends Equatable { const AppEvent(); @@ -8,6 +11,9 @@ abstract class AppEvent extends Equatable { } /// Dispatched when the application is first started and ready to load initial data. +/// +/// This event triggers the initial data loading sequence, including fetching +/// user-specific settings and preferences. class AppStarted extends AppEvent { const AppStarted({this.initialUser}); @@ -19,9 +25,14 @@ class AppStarted extends AppEvent { } /// Dispatched when the authentication state changes (e.g., user logs in/out). +/// +/// This event signals a change in the current user, prompting the [AppBloc] +/// to update its internal user state and potentially trigger data migration +/// or re-initialization. class AppUserChanged extends AppEvent { const AppUserChanged(this.user); + /// The new user object, or null if the user has logged out. final User? user; @override @@ -29,13 +40,35 @@ class AppUserChanged extends AppEvent { } /// Dispatched to request a refresh of the user's application settings. +/// +/// This event is typically used when external changes might have occurred +/// or when a manual refresh of settings is desired. class AppSettingsRefreshed extends AppEvent { const AppSettingsRefreshed(); } -/// Dispatched to fetch the remote application configuration. -class AppConfigFetchRequested extends AppEvent { - const AppConfigFetchRequested({this.isBackgroundCheck = false}); +/// Dispatched when the user's application settings have been updated. +/// +/// This event carries the complete, updated [UserAppSettings] object, +/// allowing the [AppBloc] to update its state and persist the changes. +class AppSettingsChanged extends AppEvent { + const AppSettingsChanged(this.settings); + + /// The updated [UserAppSettings] object. + final UserAppSettings settings; + + @override + List get props => [settings]; +} + +/// Dispatched to fetch the remote application configuration periodically or +/// as a background check. +/// +/// This event is used by services like [AppStatusService] to regularly +/// check for global app status changes (e.g., maintenance mode, forced updates) +/// without necessarily showing a loading UI. +class AppPeriodicConfigFetchRequested extends AppEvent { + const AppPeriodicConfigFetchRequested({this.isBackgroundCheck = true}); /// Whether this fetch is a silent background check. /// @@ -49,66 +82,42 @@ class AppConfigFetchRequested extends AppEvent { } /// Dispatched when the user logs out. +/// +/// This event triggers the sign-out process, clearing authentication tokens +/// and resetting user-specific state. class AppLogoutRequested extends AppEvent { const AppLogoutRequested(); } -/// Dispatched when the theme mode (light/dark/system) changes. -class AppThemeModeChanged extends AppEvent { - const AppThemeModeChanged(this.themeMode); - final ThemeMode themeMode; - @override - List get props => [themeMode]; -} - -/// Dispatched when the accent color theme changes. -class AppFlexSchemeChanged extends AppEvent { - const AppFlexSchemeChanged(this.flexScheme); - final FlexScheme flexScheme; - @override - List get props => [flexScheme]; -} - -/// Dispatched when the font family changes. -class AppFontFamilyChanged extends AppEvent { - const AppFontFamilyChanged(this.fontFamily); - final String? fontFamily; - @override - List get props => [fontFamily]; -} - -/// Dispatched when the text scale factor changes. -class AppTextScaleFactorChanged extends AppEvent { - const AppTextScaleFactorChanged(this.appTextScaleFactor); - final AppTextScaleFactor appTextScaleFactor; - @override - List get props => [appTextScaleFactor]; -} - -/// Dispatched when the font weight changes. -class AppFontWeightChanged extends AppEvent { - const AppFontWeightChanged(this.fontWeight); - final AppFontWeight fontWeight; - @override - List get props => [fontWeight]; -} - /// Dispatched when a one-time user account decorator has been shown. +/// +/// This event updates the user's interaction status with specific in-feed +/// decorators, allowing the app to track completion and display frequency. class AppUserFeedDecoratorShown extends AppEvent { const AppUserFeedDecoratorShown({ required this.userId, required this.feedDecoratorType, this.isCompleted = false, }); + + /// The ID of the user for whom the decorator status is being updated. final String userId; + + /// The type of the feed decorator whose status is being updated. final FeedDecoratorType feedDecoratorType; + + /// A flag indicating whether the decorator action has been completed by the user. final bool isCompleted; + @override List get props => [userId, feedDecoratorType, isCompleted]; } /// Dispatched when user-specific data (UserAppSettings and UserContentPreferences) /// have been successfully loaded and updated in the AppBloc state. +/// +/// This event serves as a signal for other BLoCs or components that depend +/// on fully loaded user data. class AppUserDataLoaded extends AppEvent { const AppUserDataLoaded(); } From d59645becb015a0a0e26ea653ba27371a01af0e5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 07:21:32 +0100 Subject: [PATCH 21/94] refactor(bloc): streamline AppBloc and enhanceAppState - Remove direct theme/font/locale management from AppState - Integrate periodic remote config fetching - Consolidate user settings updates and persistence - Simplify event handling and state transitions --- lib/app/bloc/app_bloc.dart | 378 +++++++------------------------------ 1 file changed, 68 insertions(+), 310 deletions(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index c75724c2..75fbd801 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -22,7 +22,8 @@ part 'app_state.dart'; /// /// This BLoC is central to the application's lifecycle, reacting to user /// authentication changes, managing user preferences, and applying global -/// remote configurations. +/// remote configurations. It acts as the single source of truth for global +/// application state. /// {@endtemplate} class AppBloc extends Bloc { /// {@macro app_bloc} @@ -33,7 +34,7 @@ class AppBloc extends Bloc { required AuthRepository authenticationRepository, required DataRepository userAppSettingsRepository, required DataRepository - userContentPreferencesRepository, + userContentPreferencesRepository, required DataRepository appConfigRepository, required DataRepository userRepository, required local_config.AppEnvironment environment, @@ -43,50 +44,38 @@ class AppBloc extends Bloc { this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, - }) : _authenticationRepository = authenticationRepository, - _userAppSettingsRepository = userAppSettingsRepository, - _userContentPreferencesRepository = userContentPreferencesRepository, - _appConfigRepository = appConfigRepository, - _userRepository = userRepository, - _environment = environment, - _navigatorKey = navigatorKey, - _logger = Logger('AppBloc'), - super( - // Initial state of the app. The status is set to loadingUserData - // as the AppBloc will now handle fetching user-specific data. - // UserAppSettings and UserContentPreferences are initially null - // and will be fetched asynchronously. - AppState( - status: AppLifeCycleStatus.loadingUserData, - // settings is now nullable and will be fetched. - settings: null, - selectedBottomNavigationIndex: 0, - remoteConfig: initialRemoteConfig, // Use the pre-fetched config - initialRemoteConfigError: - initialRemoteConfigError, // Store any initial config error - environment: environment, - user: initialUser, // Set initial user if available - // Default theme settings until user settings are loaded - themeMode: _mapAppBaseTheme(AppBaseTheme.system), - flexScheme: _mapAppAccentTheme(AppAccentTheme.defaultBlue), - fontFamily: _mapFontFamily('SystemDefault'), - appTextScaleFactor: AppTextScaleFactor.medium, - locale: const Locale('en'), // Default to English - ), - ) { + }) : _authenticationRepository = authenticationRepository, + _userAppSettingsRepository = userAppSettingsRepository, + _userContentPreferencesRepository = userContentPreferencesRepository, + _appConfigRepository = appConfigRepository, + _userRepository = userRepository, + _environment = environment, + _navigatorKey = navigatorKey, + _logger = Logger('AppBloc'), + super( + // Initial state of the app. The status is set to loadingUserData + // as the AppBloc will now handle fetching user-specific data. + // UserAppSettings and UserContentPreferences are initially null + // and will be fetched asynchronously. + AppState( + status: AppLifeCycleStatus.loadingUserData, + selectedBottomNavigationIndex: 0, + remoteConfig: initialRemoteConfig, // Use the pre-fetched config + initialRemoteConfigError: + initialRemoteConfigError, // Store any initial config error + environment: environment, + user: initialUser, // Set initial user if available + ), + ) { // Register event handlers for various app-level events. - on(_onAppStarted); // New event handler + on(_onAppStarted); on(_onAppUserChanged); on(_onAppSettingsRefreshed); - on(_onAppConfigFetchRequested); + on(_onAppSettingsChanged); + on(_onAppPeriodicConfigFetchRequested); on(_onAppUserFeedDecoratorShown); on(_onLogoutRequested); - on(_onThemeModeChanged); - on(_onFlexSchemeChanged); - on(_onFontFamilyChanged); - on(_onAppTextScaleFactorChanged); - on(_onAppFontWeightChanged); - on(_onAppUserDataLoaded); // New event handler + on(_onAppUserDataLoaded); // Subscribe to the authentication repository's authStateChanges stream. // This stream is the single source of truth for the user's auth state @@ -99,7 +88,7 @@ class AppBloc extends Bloc { final AuthRepository _authenticationRepository; final DataRepository _userAppSettingsRepository; final DataRepository - _userContentPreferencesRepository; + _userContentPreferencesRepository; final DataRepository _appConfigRepository; final DataRepository _userRepository; final local_config.AppEnvironment _environment; @@ -119,7 +108,7 @@ class AppBloc extends Bloc { _logger.info('[AppBloc] AppStarted event received. Starting data load.'); // If there was a critical error during bootstrap (e.g., RemoteConfig fetch failed), - // we should immediately transition to criticalError state. + // immediately transition to criticalError state. if (state.initialRemoteConfigError != null) { _logger.severe( '[AppBloc] Initial RemoteConfig fetch failed during bootstrap. ' @@ -201,21 +190,6 @@ class AppBloc extends Bloc { '[AppBloc] UserContentPreferences fetched successfully for user: ${currentUser.id}', ); - // Map loaded settings to the AppState. - final newThemeMode = _mapAppBaseTheme( - userAppSettings.displaySettings.baseTheme, - ); - final newFlexScheme = _mapAppAccentTheme( - userAppSettings.displaySettings.accentTheme, - ); - final newFontFamily = _mapFontFamily( - userAppSettings.displaySettings.fontFamily, - ); - final newAppTextScaleFactor = _mapTextScaleFactor( - userAppSettings.displaySettings.textScaleFactor, - ); - final newLocale = Locale(userAppSettings.language.code); - final finalStatus = currentUser.appRole == AppUserRole.standardUser ? AppLifeCycleStatus.authenticated : AppLifeCycleStatus.anonymous; @@ -225,13 +199,7 @@ class AppBloc extends Bloc { status: finalStatus, user: currentUser, settings: userAppSettings, - userContentPreferences: - userContentPreferences, // Store userContentPreferences - themeMode: newThemeMode, - flexScheme: newFlexScheme, - fontFamily: newFontFamily, - appTextScaleFactor: newAppTextScaleFactor, - locale: newLocale, + userContentPreferences: userContentPreferences, initialUserPreferencesError: null, ), ); @@ -291,8 +259,8 @@ class AppBloc extends Bloc { final newStatus = newUser == null ? AppLifeCycleStatus.unauthenticated : (newUser.appRole == AppUserRole.standardUser - ? AppLifeCycleStatus.authenticated - : AppLifeCycleStatus.anonymous); + ? AppLifeCycleStatus.authenticated + : AppLifeCycleStatus.anonymous); emit(state.copyWith(status: newStatus)); @@ -351,38 +319,15 @@ class AppBloc extends Bloc { final userContentPreferences = await _userContentPreferencesRepository .read(id: state.user!.id, userId: state.user!.id); - final newThemeMode = _mapAppBaseTheme( - userAppSettings.displaySettings.baseTheme, - ); - final newFlexScheme = _mapAppAccentTheme( - userAppSettings.displaySettings.accentTheme, - ); - final newFontFamily = _mapFontFamily( - userAppSettings.displaySettings.fontFamily, - ); - final newAppTextScaleFactor = _mapTextScaleFactor( - userAppSettings.displaySettings.textScaleFactor, + _logger.info( + '[AppBloc] UserAppSettings and UserContentPreferences refreshed ' + 'successfully for user: ${state.user!.id}', ); - final newLocale = Locale(userAppSettings.language.code); - - _logger - ..info( - '_onAppSettingsRefreshed: userAppSettings.fontFamily: ${userAppSettings.displaySettings.fontFamily}', - ) - ..info( - '_onAppSettingsRefreshed: newFontFamily mapped to: $newFontFamily', - ); emit( state.copyWith( - themeMode: newThemeMode, - flexScheme: newFlexScheme, - appTextScaleFactor: newAppTextScaleFactor, - fontFamily: newFontFamily, settings: userAppSettings, - userContentPreferences: - userContentPreferences, // Store userContentPreferences - locale: newLocale, + userContentPreferences: userContentPreferences, initialUserPreferencesError: null, ), ); @@ -395,7 +340,7 @@ class AppBloc extends Bloc { emit( state.copyWith( status: AppLifeCycleStatus.criticalError, - initialUserPreferencesError: e, // Use the new error field + initialUserPreferencesError: e, ), ); } catch (e, s) { @@ -407,162 +352,34 @@ class AppBloc extends Bloc { emit( state.copyWith( status: AppLifeCycleStatus.criticalError, - initialUserPreferencesError: UnknownException( - e.toString(), - ), // Use the new error field + initialUserPreferencesError: UnknownException(e.toString()), ), ); } } - /// Handles user logout request. - void _onLogoutRequested(AppLogoutRequested event, Emitter emit) { - unawaited(_authenticationRepository.signOut()); - } - - /// Handles changes to the application's base theme mode (light, dark, system). - Future _onThemeModeChanged( - AppThemeModeChanged event, - Emitter emit, - ) async { - // Ensure settings are loaded before attempting to update. - if (state.settings == null) { - _logger.warning( - '[AppBloc] Skipping theme mode change: UserAppSettings not loaded.', - ); - return; - } - - final updatedSettings = state.settings!.copyWith( - displaySettings: state.settings!.displaySettings.copyWith( - baseTheme: event.themeMode == ThemeMode.light - ? AppBaseTheme.light - : (event.themeMode == ThemeMode.dark - ? AppBaseTheme.dark - : AppBaseTheme.system), - ), - ); - emit(state.copyWith(settings: updatedSettings, themeMode: event.themeMode)); - try { - await _userAppSettingsRepository.update( - id: updatedSettings.id, - item: updatedSettings, - userId: updatedSettings.id, - ); - _logger.info('[AppBloc] UserAppSettings updated for theme mode change.'); - } catch (e, s) { - _logger.severe( - 'Failed to persist theme mode change for user ${updatedSettings.id}.', - e, - s, - ); - } - } - - /// Handles changes to the application's accent color scheme. - Future _onFlexSchemeChanged( - AppFlexSchemeChanged event, - Emitter emit, - ) async { - // Ensure settings are loaded before attempting to update. - if (state.settings == null) { - _logger.warning( - '[AppBloc] Skipping accent scheme change: UserAppSettings not loaded.', - ); - return; - } - - final updatedSettings = state.settings!.copyWith( - displaySettings: state.settings!.displaySettings.copyWith( - accentTheme: event.flexScheme == FlexScheme.blue - ? AppAccentTheme.defaultBlue - : (event.flexScheme == FlexScheme.red - ? AppAccentTheme.newsRed - : AppAccentTheme.graphiteGray), - ), - ); - emit( - state.copyWith(settings: updatedSettings, flexScheme: event.flexScheme), - ); - try { - await _userAppSettingsRepository.update( - id: updatedSettings.id, - item: updatedSettings, - userId: updatedSettings.id, - ); - _logger.info( - '[AppBloc] UserAppSettings updated for accent scheme change.', - ); - } catch (e, s) { - _logger.severe( - 'Failed to persist accent scheme change for user ${updatedSettings.id}.', - e, - s, - ); - } - } - - /// Handles changes to the application's font family. - Future _onFontFamilyChanged( - AppFontFamilyChanged event, + /// Handles the [AppSettingsChanged] event, updating and persisting the + /// user's application settings. + /// + /// This event is dispatched when any part of the user's settings (theme, + /// font, language, etc.) is modified. The entire [UserAppSettings] object + /// is updated and then persisted to the backend. + Future _onAppSettingsChanged( + AppSettingsChanged event, Emitter emit, ) async { - // Ensure settings are loaded before attempting to update. - if (state.settings == null) { + // Ensure settings are loaded and a user is available before attempting to update. + if (state.user == null || state.settings == null) { _logger.warning( - '[AppBloc] Skipping font family change: UserAppSettings not loaded.', + '[AppBloc] Skipping AppSettingsChanged: User or UserAppSettings not loaded.', ); return; } - final updatedSettings = state.settings!.copyWith( - displaySettings: state.settings!.displaySettings.copyWith( - fontFamily: event.fontFamily ?? 'SystemDefault', - ), - ); - emit( - state.copyWith(settings: updatedSettings, fontFamily: event.fontFamily), - ); - try { - await _userAppSettingsRepository.update( - id: updatedSettings.id, - item: updatedSettings, - userId: updatedSettings.id, - ); - _logger.info('[AppBloc] UserAppSettings updated for font family change.'); - } catch (e, s) { - _logger.severe( - 'Failed to persist font family change for user ${updatedSettings.id}.', - e, - s, - ); - } - } + final updatedSettings = event.settings; - /// Handles changes to the application's text scale factor. - Future _onAppTextScaleFactorChanged( - AppTextScaleFactorChanged event, - Emitter emit, - ) async { - // Ensure settings are loaded before attempting to update. - if (state.settings == null) { - _logger.warning( - '[AppBloc] Skipping text scale factor change: UserAppSettings not loaded.', - ); - return; - } + emit(state.copyWith(settings: updatedSettings)); - final updatedSettings = state.settings!.copyWith( - displaySettings: state.settings!.displaySettings.copyWith( - textScaleFactor: event.appTextScaleFactor, - ), - ); - emit( - state.copyWith( - settings: updatedSettings, - appTextScaleFactor: event.appTextScaleFactor, - ), - ); try { await _userAppSettingsRepository.update( id: updatedSettings.id, @@ -570,87 +387,28 @@ class AppBloc extends Bloc { userId: updatedSettings.id, ); _logger.info( - '[AppBloc] UserAppSettings updated for text scale factor change.', + '[AppBloc] UserAppSettings successfully updated and persisted for user ${updatedSettings.id}.', ); - } catch (e, s) { + } on HttpException catch (e) { _logger.severe( - 'Failed to persist text scale factor change for user ${updatedSettings.id}.', - e, - s, - ); - } - } - - /// Handles changes to the application's font weight. - Future _onAppFontWeightChanged( - AppFontWeightChanged event, - Emitter emit, - ) async { - // Ensure settings are loaded before attempting to update. - if (state.settings == null) { - _logger.warning( - '[AppBloc] Skipping font weight change: UserAppSettings not loaded.', + 'Failed to persist UserAppSettings for user ${updatedSettings.id} (HttpException): $e', ); - return; - } - - final updatedSettings = state.settings!.copyWith( - displaySettings: state.settings!.displaySettings.copyWith( - fontWeight: event.fontWeight, - ), - ); - emit(state.copyWith(settings: updatedSettings)); - try { - await _userAppSettingsRepository.update( - id: updatedSettings.id, - item: updatedSettings, - userId: updatedSettings.id, - ); - _logger.info('[AppBloc] UserAppSettings updated for font weight change.'); + // Revert to original settings on failure to maintain state consistency + emit(state.copyWith(settings: state.settings)); } catch (e, s) { _logger.severe( - 'Failed to persist font weight change for user ${updatedSettings.id}.', + 'Unexpected error persisting UserAppSettings for user ${updatedSettings.id}.', e, s, ); + // Revert to original settings on failure to maintain state consistency + emit(state.copyWith(settings: state.settings)); } } - /// Maps [AppBaseTheme] enum to Flutter's [ThemeMode]. - static ThemeMode _mapAppBaseTheme(AppBaseTheme mode) { - switch (mode) { - case AppBaseTheme.light: - return ThemeMode.light; - case AppBaseTheme.dark: - return ThemeMode.dark; - case AppBaseTheme.system: - return ThemeMode.system; - } - } - - /// Maps [AppAccentTheme] enum to FlexColorScheme's [FlexScheme]. - static FlexScheme _mapAppAccentTheme(AppAccentTheme name) { - switch (name) { - case AppAccentTheme.defaultBlue: - return FlexScheme.blue; - case AppAccentTheme.newsRed: - return FlexScheme.red; - case AppAccentTheme.graphiteGray: - return FlexScheme.material; - } - } - - /// Maps a font family string to a nullable string for theme data. - static String? _mapFontFamily(String fontFamilyString) { - if (fontFamilyString == 'SystemDefault') { - return null; - } - return fontFamilyString; - } - - /// Maps [AppTextScaleFactor] to itself (no transformation needed). - static AppTextScaleFactor _mapTextScaleFactor(AppTextScaleFactor factor) { - return factor; + /// Handles user logout request. + void _onLogoutRequested(AppLogoutRequested event, Emitter emit) { + unawaited(_authenticationRepository.signOut()); } @override @@ -659,13 +417,13 @@ class AppBloc extends Bloc { return super.close(); } - /// Handles fetching the remote application configuration. + /// Handles periodic fetching of the remote application configuration. /// /// This method is primarily used for re-fetching the remote configuration /// (e.g., by [AppStatusService] for background checks or by [StatusPage] /// for retries). The initial remote configuration is fetched during bootstrap. - Future _onAppConfigFetchRequested( - AppConfigFetchRequested event, + Future _onAppPeriodicConfigFetchRequested( + AppPeriodicConfigFetchRequested event, Emitter emit, ) async { if (state.user == null) { From 81b7a08dc6523966d9278bbf983637630c1e81f7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 07:22:13 +0100 Subject: [PATCH 22/94] refactor(app): improve code structure and naming - Rename AppPeriodicConfigFetchRequested event - Pass navigatorKey to AppBloc in MultiBlocProvider - Adjust formatting and indentation for better readability - Use state properties for theme configuration --- lib/app/view/app.dart | 120 ++++++++++++++++++++++-------------------- 1 file changed, 64 insertions(+), 56 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index acf83bdb..b1bb8851 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -15,7 +15,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/app/services/dem import 'package:flutter_news_app_mobile_client_full_source_code/authentication/bloc/authentication_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/router.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/status/view/view.dart'; // Import view.dart for CriticalErrorPage +import 'package:flutter_news_app_mobile_client_full_source_code/status/view/view.dart'; import 'package:go_router/go_router.dart'; import 'package:kv_storage_service/kv_storage_service.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -38,7 +38,7 @@ class App extends StatelessWidget { required DataRepository sourcesRepository, required DataRepository userAppSettingsRepository, required DataRepository - userContentPreferencesRepository, + userContentPreferencesRepository, required DataRepository remoteConfigRepository, required DataRepository userRepository, required KVStorageService kvStorageService, @@ -52,23 +52,23 @@ class App extends StatelessWidget { this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, - }) : _authenticationRepository = authenticationRepository, - _headlinesRepository = headlinesRepository, - _topicsRepository = topicsRepository, - _countriesRepository = countriesRepository, - _sourcesRepository = sourcesRepository, - _userAppSettingsRepository = userAppSettingsRepository, - _userContentPreferencesRepository = userContentPreferencesRepository, - _appConfigRepository = remoteConfigRepository, - _userRepository = userRepository, - _kvStorageService = kvStorageService, - _environment = environment, - _adService = adService, - _localAdRepository = localAdRepository, - _navigatorKey = navigatorKey, - _inlineAdCacheService = inlineAdCacheService, - _initialRemoteConfig = initialRemoteConfig, - _initialRemoteConfigError = initialRemoteConfigError; + }) : _authenticationRepository = authenticationRepository, + _headlinesRepository = headlinesRepository, + _topicsRepository = topicsRepository, + _countriesRepository = countriesRepository, + _sourcesRepository = sourcesRepository, + _userAppSettingsRepository = userAppSettingsRepository, + _userContentPreferencesRepository = userContentPreferencesRepository, + _appConfigRepository = remoteConfigRepository, + _userRepository = userRepository, + _kvStorageService = kvStorageService, + _environment = environment, + _adService = adService, + _localAdRepository = localAdRepository, + _navigatorKey = navigatorKey, + _inlineAdCacheService = inlineAdCacheService, + _initialRemoteConfig = initialRemoteConfig, + _initialRemoteConfigError = initialRemoteConfigError; final AuthRepository _authenticationRepository; final DataRepository _headlinesRepository; @@ -77,7 +77,7 @@ class App extends StatelessWidget { final DataRepository _sourcesRepository; final DataRepository _userAppSettingsRepository; final DataRepository - _userContentPreferencesRepository; + _userContentPreferencesRepository; final DataRepository _appConfigRepository; final DataRepository _userRepository; final KVStorageService _kvStorageService; @@ -118,24 +118,28 @@ class App extends StatelessWidget { child: MultiBlocProvider( providers: [ BlocProvider( - create: (context) => AppBloc( - authenticationRepository: context.read(), - userAppSettingsRepository: - context.read>(), - userContentPreferencesRepository: - context.read>(), - appConfigRepository: context.read>(), - userRepository: context.read>(), - environment: _environment, - demoDataMigrationService: demoDataMigrationService, - demoDataInitializerService: demoDataInitializerService, - initialUser: initialUser, - navigatorKey: _navigatorKey, // Pass navigatorKey to AppBloc - initialRemoteConfig: - _initialRemoteConfig, // Pass initialRemoteConfig - initialRemoteConfigError: - _initialRemoteConfigError, // Pass initialRemoteConfigError - )..add(AppStarted(initialUser: initialUser)), // Dispatch AppStarted event + create: (context) => + AppBloc( + authenticationRepository: context.read(), + userAppSettingsRepository: context + .read>(), + userContentPreferencesRepository: context + .read>(), + appConfigRepository: context + .read>(), + userRepository: context.read>(), + environment: _environment, + demoDataMigrationService: demoDataMigrationService, + demoDataInitializerService: demoDataInitializerService, + initialUser: initialUser, + navigatorKey: _navigatorKey, // Pass navigatorKey to AppBloc + initialRemoteConfig: + _initialRemoteConfig, // Pass initialRemoteConfig + initialRemoteConfigError: + _initialRemoteConfigError, // Pass initialRemoteConfigError + )..add( + AppStarted(initialUser: initialUser), + ), // Dispatch AppStarted event ), BlocProvider( create: (context) => AuthenticationBloc( @@ -310,25 +314,30 @@ class _AppViewState extends State<_AppView> { supportedLocales: AppLocalizations.supportedLocales, locale: state.locale, home: CriticalErrorPage( - exception: state.initialRemoteConfigError ?? + exception: + state.initialRemoteConfigError ?? state.initialUserPreferencesError ?? - const UnknownException('An unknown critical error occurred.'), + const UnknownException( + 'An unknown critical error occurred.', + ), onRetry: () { // If remote config failed, retry remote config. // If user preferences failed, retry AppStarted. if (state.initialRemoteConfigError != null) { context.read().add( - const AppConfigFetchRequested(isBackgroundCheck: false), - ); + const AppPeriodicConfigFetchRequested( + isBackgroundCheck: false, + ), + ); } else if (state.initialUserPreferencesError != null) { context.read().add( - AppStarted(initialUser: state.user), - ); + AppStarted(initialUser: state.user), + ); } else { // Fallback for unknown critical error context.read().add( - AppStarted(initialUser: state.user), - ); + AppStarted(initialUser: state.user), + ); } }, ), @@ -431,8 +440,9 @@ class _AppViewState extends State<_AppView> { home: LoadingStateWidget( icon: Icons.sync, headline: AppLocalizations.of(context).settingsLoadingHeadline, - subheadline: - AppLocalizations.of(context).settingsLoadingSubheadline, + subheadline: AppLocalizations.of( + context, + ).settingsLoadingSubheadline, ), ); } @@ -461,17 +471,15 @@ class _AppViewState extends State<_AppView> { themeMode: state.themeMode, theme: lightTheme( scheme: state.flexScheme, - appTextScaleFactor: - state.settings!.displaySettings.textScaleFactor, - appFontWeight: state.settings!.displaySettings.fontWeight, - fontFamily: state.settings!.displaySettings.fontFamily, + appTextScaleFactor: state.appTextScaleFactor, + appFontWeight: state.appFontWeight, + fontFamily: state.fontFamily, ), darkTheme: darkTheme( scheme: state.flexScheme, - appTextScaleFactor: - state.settings!.displaySettings.textScaleFactor, - appFontWeight: state.settings!.displaySettings.fontWeight, - fontFamily: state.settings!.displaySettings.fontFamily, + appTextScaleFactor: state.appTextScaleFactor, + appFontWeight: state.appFontWeight, + fontFamily: state.fontFamily, ), routerConfig: _router, locale: state.locale, From ed1815cf741278f5335cf971123fe8c577710c4d Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 07:22:33 +0100 Subject: [PATCH 23/94] fix(app): use AppPeriodicConfigFetchRequested for background checks - Replace AppConfigFetchRequested with AppPeriodicConfigFetchRequested in app resume event handling - Update logger messages for better readability --- lib/app/services/app_status_service.dart | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/app/services/app_status_service.dart b/lib/app/services/app_status_service.dart index 2e28d19f..f331e266 100644 --- a/lib/app/services/app_status_service.dart +++ b/lib/app/services/app_status_service.dart @@ -75,7 +75,7 @@ class AppStatusService with WidgetsBindingObserver { ); // Add the event to the AppBloc to fetch the latest config. _context.read().add( - const AppConfigFetchRequested(isBackgroundCheck: true), + const AppPeriodicConfigFetchRequested(isBackgroundCheck: true), ); }); } @@ -89,18 +89,22 @@ class AppStatusService with WidgetsBindingObserver { // useful on web, where switching browser tabs would otherwise trigger // a reload, which is unnecessary and can be distracting for demos. if (_environment == AppEnvironment.demo) { - _logger.info('[AppStatusService] Demo mode: Skipping app lifecycle check.'); + _logger.info( + '[AppStatusService] Demo mode: Skipping app lifecycle check.', + ); return; } // We are only interested in the 'resumed' state. if (state == AppLifecycleState.resumed) { - _logger.info('[AppStatusService] App resumed. Requesting AppConfig fetch.'); + _logger.info( + '[AppStatusService] App resumed. Requesting AppConfig fetch.', + ); // When the app comes to the foreground, immediately trigger a check. // This is crucial for catching maintenance mode that was enabled // while the app was in the background. _context.read().add( - const AppConfigFetchRequested(isBackgroundCheck: true), + const AppPeriodicConfigFetchRequested(isBackgroundCheck: true), ); } } From a1248fce8592bccbba530227b57989e33d820006 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 08:01:01 +0100 Subject: [PATCH 24/94] refactor(app): remove unused AppUserDataLoaded event - Deleted the AppUserDataLoaded event class from app_event.dart - This event was likely made obsolete by recent changes in the app's architecture --- lib/app/bloc/app_event.dart | 9 --------- 1 file changed, 9 deletions(-) diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart index ae675881..8861a7c5 100644 --- a/lib/app/bloc/app_event.dart +++ b/lib/app/bloc/app_event.dart @@ -112,12 +112,3 @@ class AppUserFeedDecoratorShown extends AppEvent { @override List get props => [userId, feedDecoratorType, isCompleted]; } - -/// Dispatched when user-specific data (UserAppSettings and UserContentPreferences) -/// have been successfully loaded and updated in the AppBloc state. -/// -/// This event serves as a signal for other BLoCs or components that depend -/// on fully loaded user data. -class AppUserDataLoaded extends AppEvent { - const AppUserDataLoaded(); -} From c60f14f118d74a33b665a86f158cbcf918c27621 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 08:01:16 +0100 Subject: [PATCH 25/94] refactor(AppBloc): centralize user data fetching logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract user data fetching logic into a private method `_fetchAndSetUserData` - Remove redundant `AppUserDataLoaded` event and its handler - Update user data fetching流程 in multiple methods to use the new centralized logic - Improve error handling and state updates --- lib/app/bloc/app_bloc.dart | 204 +++++++++++++++---------------------- 1 file changed, 80 insertions(+), 124 deletions(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 75fbd801..b44ec858 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -75,7 +75,6 @@ class AppBloc extends Bloc { on(_onAppPeriodicConfigFetchRequested); on(_onAppUserFeedDecoratorShown); on(_onLogoutRequested); - on(_onAppUserDataLoaded); // Subscribe to the authentication repository's authStateChanges stream. // This stream is the single source of truth for the user's auth state @@ -102,6 +101,71 @@ class AppBloc extends Bloc { /// Provides access to the [NavigatorState] for obtaining a [BuildContext]. GlobalKey get navigatorKey => _navigatorKey; + /// Fetches [UserAppSettings] and [UserContentPreferences] for the given + /// [user] and updates the [AppState]. + /// + /// This method centralizes the logic for loading user-specific data, + /// ensuring consistency across different app lifecycle events. + /// It also handles potential [HttpException]s during the fetch operation. + Future _fetchAndSetUserData(User user, Emitter emit) async { + _logger.info( + '[AppBloc] Fetching user settings and preferences for user: ${user.id}', + ); + try { + final userAppSettings = await _userAppSettingsRepository.read( + id: user.id, + userId: user.id, + ); + _logger.info( + '[AppBloc] UserAppSettings fetched successfully for user: ${user.id}', + ); + + final userContentPreferences = await _userContentPreferencesRepository + .read(id: user.id, userId: user.id); + _logger.info( + '[AppBloc] UserContentPreferences fetched successfully for user: ${user.id}', + ); + + final finalStatus = user.appRole == AppUserRole.standardUser + ? AppLifeCycleStatus.authenticated + : AppLifeCycleStatus.anonymous; + + emit( + state.copyWith( + status: finalStatus, + user: user, + settings: userAppSettings, + userContentPreferences: userContentPreferences, + initialUserPreferencesError: null, + ), + ); + } on HttpException catch (e) { + _logger.severe( + '[AppBloc] Failed to fetch user settings or preferences (HttpException) ' + 'for user ${user.id}: ${e.runtimeType} - ${e.message}', + ); + emit( + state.copyWith( + status: AppLifeCycleStatus.criticalError, + initialUserPreferencesError: e, + ), + ); + } catch (e, s) { + _logger.severe( + '[AppBloc] Unexpected error during user settings/preferences fetch ' + 'for user ${user.id}.', + e, + s, + ); + emit( + state.copyWith( + status: AppLifeCycleStatus.criticalError, + initialUserPreferencesError: UnknownException(e.toString()), + ), + ); + } + } + /// Handles the [AppStarted] event, orchestrating the initial loading of /// user-specific data and evaluating the overall app status. Future _onAppStarted(AppStarted event, Emitter emit) async { @@ -168,68 +232,7 @@ class AppBloc extends Bloc { } // User is present, proceed to fetch user-specific settings and preferences. - _logger.info( - '[AppBloc] Initial user found: ${currentUser.id} (${currentUser.appRole}). ' - 'Fetching user settings and preferences.', - ); - - try { - // Fetch UserAppSettings - final userAppSettings = await _userAppSettingsRepository.read( - id: currentUser.id, - userId: currentUser.id, - ); - _logger.info( - '[AppBloc] UserAppSettings fetched successfully for user: ${currentUser.id}', - ); - - // Fetch UserContentPreferences - final userContentPreferences = await _userContentPreferencesRepository - .read(id: currentUser.id, userId: currentUser.id); - _logger.info( - '[AppBloc] UserContentPreferences fetched successfully for user: ${currentUser.id}', - ); - - final finalStatus = currentUser.appRole == AppUserRole.standardUser - ? AppLifeCycleStatus.authenticated - : AppLifeCycleStatus.anonymous; - - emit( - state.copyWith( - status: finalStatus, - user: currentUser, - settings: userAppSettings, - userContentPreferences: userContentPreferences, - initialUserPreferencesError: null, - ), - ); - // Dispatch event to signal that user settings are loaded - add(const AppUserDataLoaded()); - } on HttpException catch (e) { - _logger.severe( - '[AppBloc] Failed to fetch user settings or preferences (HttpException) for user ' - '${currentUser.id}: ${e.runtimeType} - ${e.message}', - ); - emit( - state.copyWith( - status: AppLifeCycleStatus.criticalError, - initialUserPreferencesError: e, - ), - ); - } catch (e, s) { - _logger.severe( - '[AppBloc] Unexpected error during user settings/preferences fetch for user ' - '${currentUser.id}.', - e, - s, - ); - emit( - state.copyWith( - status: AppLifeCycleStatus.criticalError, - initialUserPreferencesError: UnknownException(e.toString()), - ), - ); - } + await _fetchAndSetUserData(currentUser, emit); } /// Handles all logic related to user authentication state changes. @@ -264,6 +267,19 @@ class AppBloc extends Bloc { emit(state.copyWith(status: newStatus)); + // If a new user is present, fetch their specific data. + if (newUser != null) { + await _fetchAndSetUserData(newUser, emit); + } else { + // If user logs out, clear user-specific data from state. + emit( + state.copyWith( + settings: null, + userContentPreferences: null, + ), + ); + } + // In demo mode, ensure user-specific data is initialized. if (_environment == local_config.AppEnvironment.demo && demoDataInitializerService != null && @@ -309,53 +325,7 @@ class AppBloc extends Bloc { return; } - try { - final userAppSettings = await _userAppSettingsRepository.read( - id: state.user!.id, - userId: state.user!.id, - ); - - // Also fetch UserContentPreferences when settings are refreshed - final userContentPreferences = await _userContentPreferencesRepository - .read(id: state.user!.id, userId: state.user!.id); - - _logger.info( - '[AppBloc] UserAppSettings and UserContentPreferences refreshed ' - 'successfully for user: ${state.user!.id}', - ); - - emit( - state.copyWith( - settings: userAppSettings, - userContentPreferences: userContentPreferences, - initialUserPreferencesError: null, - ), - ); - // Dispatch event to signal that user settings are loaded - add(const AppUserDataLoaded()); - } on HttpException catch (e) { - _logger.severe( - 'Error loading user app settings or preferences in AppBloc (HttpException): $e', - ); - emit( - state.copyWith( - status: AppLifeCycleStatus.criticalError, - initialUserPreferencesError: e, - ), - ); - } catch (e, s) { - _logger.severe( - 'Error loading user app settings or preferences in AppBloc.', - e, - s, - ); - emit( - state.copyWith( - status: AppLifeCycleStatus.criticalError, - initialUserPreferencesError: UnknownException(e.toString()), - ), - ); - } + await _fetchAndSetUserData(state.user!, emit); } /// Handles the [AppSettingsChanged] event, updating and persisting the @@ -565,18 +535,4 @@ class AppBloc extends Bloc { } } } - - /// Handles the [AppUserDataLoaded] event. - /// This event is dispatched when user-specific settings (UserAppSettings and - /// UserContentPreferences) have been successfully loaded and updated in the - /// AppBloc state. This handler currently does nothing, but it serves as a - /// placeholder for future logic that might need to react to this event. - Future _onAppUserDataLoaded( - AppUserDataLoaded event, - Emitter emit, - ) async { - _logger.info('[AppBloc] AppUserSettingsLoaded event received.'); - // This event is primarily for other BLoCs to listen to. - // AppBloc itself doesn't need to do anything further here. - } } From 27ffefc5347e3852dd9b737baa98dc5b7e17eea9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 08:39:57 +0100 Subject: [PATCH 26/94] refactor(app): split AppSettingsRefreshed into more specific events - Rename AppSettingsRefreshed to AppUserAppSettingsRefreshed - Add new event AppUserContentPreferencesRefreshed - Update documentation for new events --- lib/app/bloc/app_event.dart | 12 ++++++++++-- lib/settings/view/feed_settings_page.dart | 2 +- lib/settings/view/language_settings_page.dart | 2 +- lib/settings/view/theme_settings_page.dart | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart index 8861a7c5..7d1fb27c 100644 --- a/lib/app/bloc/app_event.dart +++ b/lib/app/bloc/app_event.dart @@ -43,8 +43,16 @@ class AppUserChanged extends AppEvent { /// /// This event is typically used when external changes might have occurred /// or when a manual refresh of settings is desired. -class AppSettingsRefreshed extends AppEvent { - const AppSettingsRefreshed(); +class AppUserAppSettingsRefreshed extends AppEvent { + const AppUserAppSettingsRefreshed(); +} + +/// Dispatched to request a refresh of the user's content preferences. +/// +/// This event is typically used when external changes might have occurred +/// or when a manual refresh of preferences is desired. +class AppUserContentPreferencesRefreshed extends AppEvent { + const AppUserContentPreferencesRefreshed(); } /// Dispatched when the user's application settings have been updated. diff --git a/lib/settings/view/feed_settings_page.dart b/lib/settings/view/feed_settings_page.dart index 2ee5ac1c..3ab02387 100644 --- a/lib/settings/view/feed_settings_page.dart +++ b/lib/settings/view/feed_settings_page.dart @@ -43,7 +43,7 @@ class FeedSettingsPage extends StatelessWidget { return BlocListener( listener: (context, settingsState) { if (settingsState.status == SettingsStatus.success) { - context.read().add(const AppSettingsRefreshed()); + context.read().add(const AppUserAppSettingsRefreshed()); } }, child: Scaffold( diff --git a/lib/settings/view/language_settings_page.dart b/lib/settings/view/language_settings_page.dart index aeff18bf..cc06669e 100644 --- a/lib/settings/view/language_settings_page.dart +++ b/lib/settings/view/language_settings_page.dart @@ -37,7 +37,7 @@ class LanguageSettingsPage extends StatelessWidget { return BlocListener( listener: (context, state) { if (state.status == SettingsStatus.success) { - context.read().add(const AppSettingsRefreshed()); + context.read().add(const AppUserAppSettingsRefreshed()); } }, child: Scaffold( diff --git a/lib/settings/view/theme_settings_page.dart b/lib/settings/view/theme_settings_page.dart index f4d5e256..cb4714c4 100644 --- a/lib/settings/view/theme_settings_page.dart +++ b/lib/settings/view/theme_settings_page.dart @@ -63,7 +63,7 @@ class ThemeSettingsPage extends StatelessWidget { // A more robust check might involve comparing previous and current userAppSettings // For now, refreshing on any success after an interaction is reasonable. // Ensure AppBloc is available in context before reading - context.read().add(const AppSettingsRefreshed()); + context.read().add(const AppUserAppSettingsRefreshed()); } // Optionally, show a SnackBar for errors if not handled globally // if (settingsState.status == SettingsStatus.failure && settingsState.error != null) { From 38ba5dd4545979d80eead4df9023fa161936e8b5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 08:40:10 +0100 Subject: [PATCH 27/94] refactor(app): split user settings fetch into separate functions - Separated fetchAndSetUserData into fetchAndSetUserSettings and fetchAndSetUserContentPreferences - Created new event handlers for user app settings and content preferences refresh - Updated AppBloc to handle new events and use new fetch functions - Improved error handling and logging for user settings and content preferences fetch operations --- lib/app/bloc/app_bloc.dart | 106 +++++++++++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 22 deletions(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index b44ec858..241fed9e 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -70,7 +70,8 @@ class AppBloc extends Bloc { // Register event handlers for various app-level events. on(_onAppStarted); on(_onAppUserChanged); - on(_onAppSettingsRefreshed); + on(_onUserAppSettingsRefreshed); + on(_onUserContentPreferencesRefreshed); on(_onAppSettingsChanged); on(_onAppPeriodicConfigFetchRequested); on(_onAppUserFeedDecoratorShown); @@ -108,8 +109,29 @@ class AppBloc extends Bloc { /// ensuring consistency across different app lifecycle events. /// It also handles potential [HttpException]s during the fetch operation. Future _fetchAndSetUserData(User user, Emitter emit) async { + await _fetchAndSetUserSettings(user, emit); + await _fetchAndSetUserContentPreferences(user, emit); + + final finalStatus = user.appRole == AppUserRole.standardUser + ? AppLifeCycleStatus.authenticated + : AppLifeCycleStatus.anonymous; + + emit( + state.copyWith( + status: finalStatus, + user: user, + initialUserPreferencesError: null, + ), + ); + } + + /// Fetches [UserAppSettings] for the given [user] and updates the [AppState]. + Future _fetchAndSetUserSettings( + User user, + Emitter emit, + ) async { _logger.info( - '[AppBloc] Fetching user settings and preferences for user: ${user.id}', + '[AppBloc] Fetching user settings for user: ${user.id}', ); try { final userAppSettings = await _userAppSettingsRepository.read( @@ -119,29 +141,52 @@ class AppBloc extends Bloc { _logger.info( '[AppBloc] UserAppSettings fetched successfully for user: ${user.id}', ); + emit(state.copyWith(settings: userAppSettings)); + } on HttpException catch (e) { + _logger.severe( + '[AppBloc] Failed to fetch user settings (HttpException) ' + 'for user ${user.id}: ${e.runtimeType} - ${e.message}', + ); + emit( + state.copyWith( + status: AppLifeCycleStatus.criticalError, + initialUserPreferencesError: e, + ), + ); + } catch (e, s) { + _logger.severe( + '[AppBloc] Unexpected error during user settings fetch ' + 'for user ${user.id}.', + e, + s, + ); + emit( + state.copyWith( + status: AppLifeCycleStatus.criticalError, + initialUserPreferencesError: UnknownException(e.toString()), + ), + ); + } + } + /// Fetches [UserContentPreferences] for the given [user] and updates the [AppState]. + Future _fetchAndSetUserContentPreferences( + User user, + Emitter emit, + ) async { + _logger.info( + '[AppBloc] Fetching user content preferences for user: ${user.id}', + ); + try { final userContentPreferences = await _userContentPreferencesRepository .read(id: user.id, userId: user.id); _logger.info( '[AppBloc] UserContentPreferences fetched successfully for user: ${user.id}', ); - - final finalStatus = user.appRole == AppUserRole.standardUser - ? AppLifeCycleStatus.authenticated - : AppLifeCycleStatus.anonymous; - - emit( - state.copyWith( - status: finalStatus, - user: user, - settings: userAppSettings, - userContentPreferences: userContentPreferences, - initialUserPreferencesError: null, - ), - ); + emit(state.copyWith(userContentPreferences: userContentPreferences)); } on HttpException catch (e) { _logger.severe( - '[AppBloc] Failed to fetch user settings or preferences (HttpException) ' + '[AppBloc] Failed to fetch user content preferences (HttpException) ' 'for user ${user.id}: ${e.runtimeType} - ${e.message}', ); emit( @@ -152,7 +197,7 @@ class AppBloc extends Bloc { ); } catch (e, s) { _logger.severe( - '[AppBloc] Unexpected error during user settings/preferences fetch ' + '[AppBloc] Unexpected error during user content preferences fetch ' 'for user ${user.id}.', e, s, @@ -316,16 +361,33 @@ class AppBloc extends Bloc { } /// Handles refreshing/loading app settings (theme, font). - Future _onAppSettingsRefreshed( - AppSettingsRefreshed event, + Future _onUserAppSettingsRefreshed( + AppUserAppSettingsRefreshed event, Emitter emit, ) async { if (state.user == null) { - _logger.info('[AppBloc] Skipping AppSettingsRefreshed: User is null.'); + _logger.info( + '[AppBloc] Skipping AppUserAppSettingsRefreshed: User is null.', + ); + return; + } + + await _fetchAndSetUserSettings(state.user!, emit); + } + + /// Handles refreshing/loading user content preferences. + Future _onUserContentPreferencesRefreshed( + AppUserContentPreferencesRefreshed event, + Emitter emit, + ) async { + if (state.user == null) { + _logger.info( + '[AppBloc] Skipping AppUserContentPreferencesRefreshed: User is null.', + ); return; } - await _fetchAndSetUserData(state.user!, emit); + await _fetchAndSetUserContentPreferences(state.user!, emit); } /// Handles the [AppSettingsChanged] event, updating and persisting the From 49e2be1d7dbefc6141a8a2225c6ecbe89af417f7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 08:45:53 +0100 Subject: [PATCH 28/94] fix(settings): update font settings page to use correct event - Replace AppSettingsRefreshed with AppUserAppSettingsRefreshed - This change ensures that the font settings page correctly triggers - the appropriate event when settings are successfully updated --- lib/settings/view/font_settings_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/settings/view/font_settings_page.dart b/lib/settings/view/font_settings_page.dart index a15380a7..52e46fa3 100644 --- a/lib/settings/view/font_settings_page.dart +++ b/lib/settings/view/font_settings_page.dart @@ -68,7 +68,7 @@ class FontSettingsPage extends StatelessWidget { listener: (context, settingsState) { // Renamed state to avoid conflict if (settingsState.status == SettingsStatus.success) { - context.read().add(const AppSettingsRefreshed()); + context.read().add(const AppUserAppSettingsRefreshed()); } }, child: Scaffold( From c0f829d3bf54a408fb4905310825c5b9ea745921 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 18:21:17 +0100 Subject: [PATCH 29/94] refactor(router): remove unused AccountBloc parameter - Remove userContentPreferencesRepository parameter from AccountBloc instantiation --- lib/router/router.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index 56ab4b9a..9d79be3d 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -74,7 +74,6 @@ GoRouter createRouter({ // Instantiate AccountBloc once to be shared final accountBloc = AccountBloc( authenticationRepository: authenticationRepository, - userContentPreferencesRepository: userContentPreferencesRepository, ); // Instantiate FeedDecoratorService once to be shared From 76a6b3a999e63f0d7ed08647c4727ce24af9ae88 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 18:21:44 +0100 Subject: [PATCH 30/94] feat(app): add event for user content preferences changes - Introduce new AppUserContentPreferencesChanged event - This event allows the AppBloc to update user content preferences - The event carries the complete, updated UserContentPreferences object - Helps in persisting the changes and updating the app state --- lib/app/bloc/app_event.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart index 7d1fb27c..e48dc4bd 100644 --- a/lib/app/bloc/app_event.dart +++ b/lib/app/bloc/app_event.dart @@ -97,6 +97,20 @@ class AppLogoutRequested extends AppEvent { const AppLogoutRequested(); } +/// Dispatched when the user's content preferences have been updated. +/// +/// This event carries the complete, updated [UserContentPreferences] object, +/// allowing the [AppBloc] to update its state and persist the changes. +class AppUserContentPreferencesChanged extends AppEvent { + const AppUserContentPreferencesChanged({required this.preferences}); + + /// The updated [UserContentPreferences] object. + final UserContentPreferences preferences; + + @override + List get props => [preferences]; +} + /// Dispatched when a one-time user account decorator has been shown. /// /// This event updates the user's interaction status with specific in-feed From ef192c4e9069a21bc9a5f24641769876c64763a1 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 18:21:51 +0100 Subject: [PATCH 31/94] feat(app): implement user content preferences update in AppBloc - Add event handler for AppUserContentPreferencesChanged event - Optimistically update state with new preferences - Persist changes to the backend - Handle errors and revert state if persistence fails --- lib/app/bloc/app_bloc.dart | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 241fed9e..44f92808 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -75,6 +75,7 @@ class AppBloc extends Bloc { on(_onAppSettingsChanged); on(_onAppPeriodicConfigFetchRequested); on(_onAppUserFeedDecoratorShown); + on(_onAppUserContentPreferencesChanged); on(_onLogoutRequested); // Subscribe to the authentication repository's authStateChanges stream. @@ -597,4 +598,55 @@ class AppBloc extends Bloc { } } } + + /// Handles updating the user's content preferences. + /// + /// This event is dispatched by UI components when the user modifies their + /// followed countries, sources, topics, or saved headlines. The event + /// carries the complete, updated [UserContentPreferences] object, which + /// is then persisted to the backend. + Future _onAppUserContentPreferencesChanged( + AppUserContentPreferencesChanged event, + Emitter emit, + ) async { + // Ensure a user is available before attempting to update preferences. + if (state.user == null) { + _logger.warning( + '[AppBloc] Skipping AppUserContentPreferencesChanged: User is null.', + ); + return; + } + + final updatedPreferences = event.preferences; + + // Optimistically update the state. + emit(state.copyWith(userContentPreferences: updatedPreferences)); + + try { + await _userContentPreferencesRepository.update( + id: updatedPreferences.id, + item: updatedPreferences, + userId: updatedPreferences.id, + ); + _logger.info( + '[AppBloc] UserContentPreferences successfully updated and persisted ' + 'for user ${updatedPreferences.id}.', + ); + } on HttpException catch (e) { + _logger.severe( + 'Failed to persist UserContentPreferences for user ${updatedPreferences.id} ' + '(HttpException): $e', + ); + // Revert to original preferences on failure to maintain state consistency. + emit(state.copyWith(userContentPreferences: state.userContentPreferences)); + } catch (e, s) { + _logger.severe( + 'Unexpected error persisting UserContentPreferences for user ${updatedPreferences.id}.', + e, + s, + ); + // Revert to original preferences on failure to maintain state consistency. + emit(state.copyWith(userContentPreferences: state.userContentPreferences)); + } + } } From f5fc7dc6308ec643351ff81740564206ee4fbdc0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 18:22:17 +0100 Subject: [PATCH 32/94] refactor(account): remove preferences from AccountState - Remove UserContentPreferences from AccountState - Update copyWith method to reflect the removal - Adjust props list to exclude preferences --- lib/account/bloc/account_state.dart | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/account/bloc/account_state.dart b/lib/account/bloc/account_state.dart index b1c3a321..191743db 100644 --- a/lib/account/bloc/account_state.dart +++ b/lib/account/bloc/account_state.dart @@ -6,32 +6,27 @@ class AccountState extends Equatable { const AccountState({ this.status = AccountStatus.initial, this.user, - this.preferences, this.error, }); final AccountStatus status; final User? user; - final UserContentPreferences? preferences; final HttpException? error; AccountState copyWith({ AccountStatus? status, User? user, - UserContentPreferences? preferences, HttpException? error, bool clearUser = false, - bool clearPreferences = false, bool clearError = false, }) { return AccountState( status: status ?? this.status, user: clearUser ? null : user ?? this.user, - preferences: clearPreferences ? null : preferences ?? this.preferences, error: clearError ? null : error ?? this.error, ); } @override - List get props => [status, user, preferences, error]; + List get props => [status, user, error]; } From 6ee2e6981f3d58b709919569384c787a1f348714 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 18:22:37 +0100 Subject: [PATCH 33/94] refactor(account): remove unused account events - Removed AccountLoadUserPreferences, AccountSaveHeadlineToggled, AccountFollowTopicToggled, AccountFollowSourceToggled, and AccountFollowCountryToggled events - These events were likely moved to the preferences feature --- lib/account/bloc/account_event.dart | 40 ----------------------------- 1 file changed, 40 deletions(-) diff --git a/lib/account/bloc/account_event.dart b/lib/account/bloc/account_event.dart index fe346acf..7fde5638 100644 --- a/lib/account/bloc/account_event.dart +++ b/lib/account/bloc/account_event.dart @@ -16,46 +16,6 @@ class AccountUserChanged extends AccountEvent { List get props => [user]; } -class AccountLoadUserPreferences extends AccountEvent { - // Corrected name - const AccountLoadUserPreferences({required this.userId}); - final String userId; - - @override - List get props => [userId]; -} - -class AccountSaveHeadlineToggled extends AccountEvent { - const AccountSaveHeadlineToggled({required this.headline}); - final Headline headline; - - @override - List get props => [headline]; -} - -class AccountFollowTopicToggled extends AccountEvent { - const AccountFollowTopicToggled({required this.topic}); - final Topic topic; - - @override - List get props => [topic]; -} - -class AccountFollowSourceToggled extends AccountEvent { - const AccountFollowSourceToggled({required this.source}); - final Source source; - - @override - List get props => [source]; -} - -class AccountFollowCountryToggled extends AccountEvent { - const AccountFollowCountryToggled({required this.country}); - final Country country; - - @override - List get props => [country]; -} class AccountClearUserPreferences extends AccountEvent { const AccountClearUserPreferences({required this.userId}); From 67a4a9a1c8d22dbd421ae9bdd989680344e9b505 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 18:23:19 +0100 Subject: [PATCH 34/94] refactor(account): remove UserContentPreferences handling - Remove UserContentPreferences repository and related logic - Simplify AccountBloc by removing unused events and handlers - Update user preference clearing logic to be handled by AppBloc - Remove local sorting of preferences --- lib/account/bloc/account_bloc.dart | 426 +---------------------------- 1 file changed, 9 insertions(+), 417 deletions(-) diff --git a/lib/account/bloc/account_bloc.dart b/lib/account/bloc/account_bloc.dart index 6fc8cafb..a0ca8ca0 100644 --- a/lib/account/bloc/account_bloc.dart +++ b/lib/account/bloc/account_bloc.dart @@ -13,13 +13,10 @@ part 'account_state.dart'; class AccountBloc extends Bloc { AccountBloc({ required AuthRepository authenticationRepository, - required DataRepository - userContentPreferencesRepository, Logger? logger, - }) : _authenticationRepository = authenticationRepository, - _userContentPreferencesRepository = userContentPreferencesRepository, - _logger = logger ?? Logger('AccountBloc'), - super(const AccountState()) { + }) : _authenticationRepository = authenticationRepository, + _logger = logger ?? Logger('AccountBloc'), + super(const AccountState()) { // Listen to user changes from AuthRepository _userSubscription = _authenticationRepository.authStateChanges.listen(( user, @@ -27,380 +24,23 @@ class AccountBloc extends Bloc { add(AccountUserChanged(user)); }); - // Listen to changes in UserContentPreferences from the repository. - // This ensures the AccountBloc's state is updated whenever preferences - // are created, updated, or deleted, resolving any synchronization issue. - _userContentPreferencesSubscription = _userContentPreferencesRepository - .entityUpdated - .where((type) => type == UserContentPreferences) - .listen((_) { - // If there's a current user, reload their preferences. - if (state.user?.id != null) { - add(AccountLoadUserPreferences(userId: state.user!.id)); - } - }); - // Register event handlers on(_onAccountUserChanged); - on(_onAccountLoadUserPreferences); - on(_onAccountSaveHeadlineToggled); - on(_onAccountFollowTopicToggled); - on(_onAccountFollowSourceToggled); - on(_onAccountFollowCountryToggled); on(_onAccountClearUserPreferences); } final AuthRepository _authenticationRepository; - final DataRepository - _userContentPreferencesRepository; final Logger _logger; late StreamSubscription _userSubscription; - late StreamSubscription _userContentPreferencesSubscription; Future _onAccountUserChanged( AccountUserChanged event, Emitter emit, ) async { emit(state.copyWith(user: event.user)); - if (event.user != null) { - add(AccountLoadUserPreferences(userId: event.user!.id)); - } else { + if (event.user == null) { // Clear preferences if user is null (logged out) - emit( - state.copyWith(clearPreferences: true, status: AccountStatus.initial), - ); - } - } - - Future _onAccountLoadUserPreferences( - AccountLoadUserPreferences event, - Emitter emit, - ) async { - emit(state.copyWith(status: AccountStatus.loading)); - try { - final preferences = await _userContentPreferencesRepository.read( - id: event.userId, - userId: event.userId, - ); - emit( - state.copyWith( - status: AccountStatus.success, - preferences: _sortPreferences(preferences), - clearError: true, - ), - ); - } on NotFoundException { - // If preferences not found, create a default one for the user. - final defaultPreferences = UserContentPreferences( - id: event.userId, - followedCountries: const [], - followedSources: const [], - followedTopics: const [], - savedHeadlines: const [], - ); - try { - await _userContentPreferencesRepository.create( - item: defaultPreferences, - userId: event.userId, - ); - emit( - state.copyWith( - preferences: _sortPreferences(defaultPreferences), - clearError: true, - status: AccountStatus.success, - ), - ); - } on ConflictException { - // If a conflict occurs during creation (e.g., another process - // created it concurrently), attempt to read it again to get the existing - // one. This can happen if the migration service created it right after - // the second NotFoundException. - _logger.info( - '[AccountBloc] Conflict during creation of UserContentPreferences. ' - 'Attempting to re-read.', - ); - final existingPreferences = await _userContentPreferencesRepository - .read(id: event.userId, userId: event.userId); - emit( - state.copyWith( - status: AccountStatus.success, - preferences: _sortPreferences(existingPreferences), - clearError: true, - ), - ); - } on HttpException catch (e) { - _logger.severe( - 'Failed to create default preferences with HttpException: $e', - ); - emit(state.copyWith(status: AccountStatus.failure, error: e)); - } catch (e, st) { - _logger.severe( - 'Failed to create default preferences with unexpected error: $e', - e, - st, - ); - emit( - state.copyWith( - status: AccountStatus.failure, - error: OperationFailedException( - 'Failed to create default preferences: $e', - ), - ), - ); - } - } on HttpException catch (e) { - _logger.severe( - 'AccountLoadUserPreferences failed with HttpException: $e', - ); - emit(state.copyWith(status: AccountStatus.failure, error: e)); - } catch (e, st) { - _logger.severe( - 'AccountLoadUserPreferences failed with unexpected error: $e', - e, - st, - ); - emit( - state.copyWith( - status: AccountStatus.failure, - error: OperationFailedException('An unexpected error occurred: $e'), - ), - ); - } - } - - Future _onAccountFollowCountryToggled( - AccountFollowCountryToggled event, - Emitter emit, - ) async { - if (state.user == null || state.preferences == null) return; - emit(state.copyWith(status: AccountStatus.loading)); - - final currentPrefs = state.preferences!; - final isCurrentlyFollowed = currentPrefs.followedCountries.any( - (c) => c.id == event.country.id, - ); - final List updatedFollowedCountries; - - updatedFollowedCountries = isCurrentlyFollowed - ? (List.from(currentPrefs.followedCountries) - ..removeWhere((c) => c.id == event.country.id)) - : (List.from(currentPrefs.followedCountries)..add(event.country)); - - final updatedPrefs = currentPrefs.copyWith( - followedCountries: updatedFollowedCountries, - ); - - try { - final sortedPrefs = _sortPreferences(updatedPrefs); - await _userContentPreferencesRepository.update( - id: state.user!.id, - item: sortedPrefs, - userId: state.user!.id, - ); - emit( - state.copyWith( - status: AccountStatus.success, - preferences: sortedPrefs, - clearError: true, - ), - ); - } on HttpException catch (e) { - _logger.severe( - 'AccountFollowCountryToggled failed with HttpException: $e', - ); - emit(state.copyWith(status: AccountStatus.failure, error: e)); - } catch (e, st) { - _logger.severe( - 'AccountFollowCountryToggled failed with unexpected error: $e', - e, - st, - ); - emit( - state.copyWith( - status: AccountStatus.failure, - error: OperationFailedException( - 'Failed to update followed countries: $e', - ), - ), - ); - } - } - - Future _onAccountSaveHeadlineToggled( - AccountSaveHeadlineToggled event, - Emitter emit, - ) async { - if (state.user == null || state.preferences == null) return; - emit(state.copyWith(status: AccountStatus.loading)); - - final currentPrefs = state.preferences!; - final isCurrentlySaved = currentPrefs.savedHeadlines.any( - (h) => h.id == event.headline.id, - ); - final List updatedSavedHeadlines; - - if (isCurrentlySaved) { - updatedSavedHeadlines = List.from(currentPrefs.savedHeadlines) - ..removeWhere((h) => h.id == event.headline.id); - } else { - updatedSavedHeadlines = List.from(currentPrefs.savedHeadlines) - ..add(event.headline); - } - - final updatedPrefs = currentPrefs.copyWith( - savedHeadlines: updatedSavedHeadlines, - ); - - try { - final sortedPrefs = _sortPreferences(updatedPrefs); - await _userContentPreferencesRepository.update( - id: state.user!.id, - item: sortedPrefs, - userId: state.user!.id, - ); - emit( - state.copyWith( - status: AccountStatus.success, - preferences: sortedPrefs, - clearError: true, - ), - ); - } on HttpException catch (e) { - _logger.severe( - 'AccountSaveHeadlineToggled failed with HttpException: $e', - ); - emit(state.copyWith(status: AccountStatus.failure, error: e)); - } catch (e, st) { - _logger.severe( - 'AccountSaveHeadlineToggled failed with unexpected error: $e', - e, - st, - ); - emit( - state.copyWith( - status: AccountStatus.failure, - error: OperationFailedException( - 'Failed to update saved headlines: $e', - ), - ), - ); - } - } - - Future _onAccountFollowTopicToggled( - AccountFollowTopicToggled event, - Emitter emit, - ) async { - if (state.user == null || state.preferences == null) return; - emit(state.copyWith(status: AccountStatus.loading)); - - final currentPrefs = state.preferences!; - final isCurrentlyFollowed = currentPrefs.followedTopics.any( - (t) => t.id == event.topic.id, - ); - final List updatedFollowedTopics; - - updatedFollowedTopics = isCurrentlyFollowed - ? (List.from(currentPrefs.followedTopics) - ..removeWhere((t) => t.id == event.topic.id)) - : (List.from(currentPrefs.followedTopics)..add(event.topic)); - - final updatedPrefs = currentPrefs.copyWith( - followedTopics: updatedFollowedTopics, - ); - - try { - final sortedPrefs = _sortPreferences(updatedPrefs); - await _userContentPreferencesRepository.update( - id: state.user!.id, - item: sortedPrefs, - userId: state.user!.id, - ); - emit( - state.copyWith( - status: AccountStatus.success, - preferences: sortedPrefs, - clearError: true, - ), - ); - } on HttpException catch (e) { - _logger.severe('AccountFollowTopicToggled failed with HttpException: $e'); - emit(state.copyWith(status: AccountStatus.failure, error: e)); - } catch (e, st) { - _logger.severe( - 'AccountFollowTopicToggled failed with unexpected error: $e', - e, - st, - ); - emit( - state.copyWith( - status: AccountStatus.failure, - error: OperationFailedException( - 'Failed to update followed topics: $e', - ), - ), - ); - } - } - - Future _onAccountFollowSourceToggled( - AccountFollowSourceToggled event, - Emitter emit, - ) async { - if (state.user == null || state.preferences == null) return; - emit(state.copyWith(status: AccountStatus.loading)); - - final currentPrefs = state.preferences!; - final isCurrentlyFollowed = currentPrefs.followedSources.any( - (s) => s.id == event.source.id, - ); - final List updatedFollowedSources; - - if (isCurrentlyFollowed) { - updatedFollowedSources = List.from(currentPrefs.followedSources) - ..removeWhere((s) => s.id == event.source.id); - } else { - updatedFollowedSources = List.from(currentPrefs.followedSources) - ..add(event.source); - } - - final updatedPrefs = currentPrefs.copyWith( - followedSources: updatedFollowedSources, - ); - - try { - final sortedPrefs = _sortPreferences(updatedPrefs); - await _userContentPreferencesRepository.update( - id: state.user!.id, - item: sortedPrefs, - userId: state.user!.id, - ); - emit( - state.copyWith( - status: AccountStatus.success, - preferences: sortedPrefs, - clearError: true, - ), - ); - } on HttpException catch (e) { - _logger.severe( - 'AccountFollowSourceToggled failed with HttpException: $e', - ); - emit(state.copyWith(status: AccountStatus.failure, error: e)); - } catch (e, st) { - _logger.severe( - 'AccountFollowSourceToggled failed with unexpected error: $e', - e, - st, - ); - emit( - state.copyWith( - status: AccountStatus.failure, - error: OperationFailedException( - 'Failed to update followed sources: $e', - ), - ), - ); + emit(state.copyWith(status: AccountStatus.initial)); } } @@ -410,26 +50,10 @@ class AccountBloc extends Bloc { ) async { emit(state.copyWith(status: AccountStatus.loading)); try { - // Create a new default preferences object to "clear" existing ones - final defaultPreferences = UserContentPreferences( - id: event.userId, - followedCountries: const [], - followedSources: const [], - followedTopics: const [], - savedHeadlines: const [], - ); - await _userContentPreferencesRepository.update( - id: event.userId, - item: defaultPreferences, - userId: event.userId, - ); - emit( - state.copyWith( - status: AccountStatus.success, - preferences: defaultPreferences, - clearError: true, - ), - ); + // This event is now handled by AppBloc. + // AccountBloc only dispatches the event to AppBloc. + // No direct repository interaction here. + emit(state.copyWith(status: AccountStatus.success, clearError: true)); } on HttpException catch (e) { _logger.severe( 'AccountClearUserPreferences failed with HttpException: $e', @@ -452,41 +76,9 @@ class AccountBloc extends Bloc { } } - /// Sorts the lists within UserContentPreferences locally. - /// - /// This client-side sorting is necessary due to a backend limitation that - /// does not support sorting for saved or followed content lists. This - /// approach remains efficient as these lists are fetched all at once and - /// are kept small by user account-type limits. - UserContentPreferences _sortPreferences(UserContentPreferences preferences) { - // Sort saved headlines by updatedAt descending (newest first) - final sortedHeadlines = List.from(preferences.savedHeadlines) - ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); - - // Sort followed topics by name ascending - final sortedTopics = List.from(preferences.followedTopics) - ..sort((a, b) => a.name.compareTo(b.name)); - - // Sort followed sources by name ascending - final sortedSources = List.from(preferences.followedSources) - ..sort((a, b) => a.name.compareTo(b.name)); - - // Sort followed countries by name ascending - final sortedCountries = List.from(preferences.followedCountries) - ..sort((a, b) => a.name.compareTo(b.name)); - - return preferences.copyWith( - savedHeadlines: sortedHeadlines, - followedTopics: sortedTopics, - followedSources: sortedSources, - followedCountries: sortedCountries, - ); - } - @override Future close() { _userSubscription.cancel(); - _userContentPreferencesSubscription.cancel(); return super.close(); } } From e7393d455d56909a0dd50546f818aa4cd7df3f54 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 18:25:18 +0100 Subject: [PATCH 35/94] refactor(account): remove account bloc and related files - Remove AccountBloc, AccountEvent, and AccountState classes - Delete account_bloc.dart, account_event.dart, and account_state.dart files - Update imports and dependencies related to account bloc --- lib/account/bloc/account_bloc.dart | 84 ----------------------------- lib/account/bloc/account_event.dart | 26 --------- lib/account/bloc/account_state.dart | 32 ----------- 3 files changed, 142 deletions(-) delete mode 100644 lib/account/bloc/account_bloc.dart delete mode 100644 lib/account/bloc/account_event.dart delete mode 100644 lib/account/bloc/account_state.dart diff --git a/lib/account/bloc/account_bloc.dart b/lib/account/bloc/account_bloc.dart deleted file mode 100644 index a0ca8ca0..00000000 --- a/lib/account/bloc/account_bloc.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'dart:async'; - -import 'package:auth_repository/auth_repository.dart'; -import 'package:bloc/bloc.dart'; -import 'package:core/core.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:equatable/equatable.dart'; -import 'package:logging/logging.dart'; - -part 'account_event.dart'; -part 'account_state.dart'; - -class AccountBloc extends Bloc { - AccountBloc({ - required AuthRepository authenticationRepository, - Logger? logger, - }) : _authenticationRepository = authenticationRepository, - _logger = logger ?? Logger('AccountBloc'), - super(const AccountState()) { - // Listen to user changes from AuthRepository - _userSubscription = _authenticationRepository.authStateChanges.listen(( - user, - ) { - add(AccountUserChanged(user)); - }); - - // Register event handlers - on(_onAccountUserChanged); - on(_onAccountClearUserPreferences); - } - - final AuthRepository _authenticationRepository; - final Logger _logger; - late StreamSubscription _userSubscription; - - Future _onAccountUserChanged( - AccountUserChanged event, - Emitter emit, - ) async { - emit(state.copyWith(user: event.user)); - if (event.user == null) { - // Clear preferences if user is null (logged out) - emit(state.copyWith(status: AccountStatus.initial)); - } - } - - Future _onAccountClearUserPreferences( - AccountClearUserPreferences event, - Emitter emit, - ) async { - emit(state.copyWith(status: AccountStatus.loading)); - try { - // This event is now handled by AppBloc. - // AccountBloc only dispatches the event to AppBloc. - // No direct repository interaction here. - emit(state.copyWith(status: AccountStatus.success, clearError: true)); - } on HttpException catch (e) { - _logger.severe( - 'AccountClearUserPreferences failed with HttpException: $e', - ); - emit(state.copyWith(status: AccountStatus.failure, error: e)); - } catch (e, st) { - _logger.severe( - 'AccountClearUserPreferences failed with unexpected error: $e', - e, - st, - ); - emit( - state.copyWith( - status: AccountStatus.failure, - error: OperationFailedException( - 'Failed to clear user preferences: $e', - ), - ), - ); - } - } - - @override - Future close() { - _userSubscription.cancel(); - return super.close(); - } -} diff --git a/lib/account/bloc/account_event.dart b/lib/account/bloc/account_event.dart deleted file mode 100644 index 7fde5638..00000000 --- a/lib/account/bloc/account_event.dart +++ /dev/null @@ -1,26 +0,0 @@ -part of 'account_bloc.dart'; - -abstract class AccountEvent extends Equatable { - const AccountEvent(); - - @override - List get props => []; -} - -class AccountUserChanged extends AccountEvent { - // Corrected name - const AccountUserChanged(this.user); - final User? user; - - @override - List get props => [user]; -} - - -class AccountClearUserPreferences extends AccountEvent { - const AccountClearUserPreferences({required this.userId}); - final String userId; - - @override - List get props => [userId]; -} diff --git a/lib/account/bloc/account_state.dart b/lib/account/bloc/account_state.dart deleted file mode 100644 index 191743db..00000000 --- a/lib/account/bloc/account_state.dart +++ /dev/null @@ -1,32 +0,0 @@ -part of 'account_bloc.dart'; - -enum AccountStatus { initial, loading, success, failure } - -class AccountState extends Equatable { - const AccountState({ - this.status = AccountStatus.initial, - this.user, - this.error, - }); - - final AccountStatus status; - final User? user; - final HttpException? error; - - AccountState copyWith({ - AccountStatus? status, - User? user, - HttpException? error, - bool clearUser = false, - bool clearError = false, - }) { - return AccountState( - status: status ?? this.status, - user: clearUser ? null : user ?? this.user, - error: clearError ? null : error ?? this.error, - ); - } - - @override - List get props => [status, user, error]; -} From 6ae8a53448c94caa22cb972f6a3d8e88617d1cdc Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 18:26:07 +0100 Subject: [PATCH 36/94] refactor(account): migrate saved headlines from AccountBloc to AppBloc - Replace AccountBloc with AppBloc for user preferences management - Update UI logic to use AppBloc state for loading and error handling - Modify saved headlines removal logic to directly update AppBloc state - Remove unnecessary imports and unused code --- lib/account/view/saved_headlines_page.dart | 53 ++++++++++++---------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/lib/account/view/saved_headlines_page.dart b/lib/account/view/saved_headlines_page.dart index 12857fe4..bacafe6e 100644 --- a/lib/account/view/saved_headlines_page.dart +++ b/lib/account/view/saved_headlines_page.dart @@ -1,10 +1,8 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; -// HeadlineItemWidget import removed import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/feed_core.dart'; @@ -35,10 +33,13 @@ class SavedHeadlinesPage extends StatelessWidget { style: textTheme.titleLarge, ), ), - body: BlocBuilder( - builder: (context, state) { - if (state.status == AccountStatus.loading && - state.preferences == null) { + body: BlocBuilder( + builder: (context, appState) { + final user = appState.user; + final userContentPreferences = appState.userContentPreferences; + + if (appState.status == AppLifeCycleStatus.loadingUserData || + userContentPreferences == null) { return LoadingStateWidget( icon: Icons.bookmarks_outlined, headline: l10n.savedHeadlinesLoadingHeadline, @@ -46,23 +47,18 @@ class SavedHeadlinesPage extends StatelessWidget { ); } - if (state.status == AccountStatus.failure && - state.preferences == null) { + if (appState.initialUserPreferencesError != null) { return FailureStateWidget( - exception: - state.error ?? - OperationFailedException(l10n.savedHeadlinesErrorHeadline), + exception: appState.initialUserPreferencesError!, onRetry: () { - if (state.user?.id != null) { - context.read().add( - AccountLoadUserPreferences(userId: state.user!.id), - ); - } + context.read().add( + AppStarted(initialUser: user), + ); }, ); } - final savedHeadlines = state.preferences?.savedHeadlines ?? []; + final savedHeadlines = userContentPreferences.savedHeadlines; if (savedHeadlines.isEmpty) { return InitialStateWidget( @@ -99,20 +95,27 @@ class SavedHeadlinesPage extends StatelessWidget { ), itemBuilder: (context, index) { final headline = savedHeadlines[index]; - final imageStyle = context - .watch() - .state - .settings - .feedPreferences - .headlineImageStyle; + final imageStyle = appState.settings?.feedPreferences.headlineImageStyle ?? + HeadlineImageStyle.smallThumbnail; // Default if settings not loaded final trailingButton = IconButton( icon: Icon(Icons.delete_outline, color: colorScheme.error), tooltip: l10n.headlineDetailsRemoveFromSavedTooltip, onPressed: () { - context.read().add( - AccountSaveHeadlineToggled(headline: headline), + final updatedSavedHeadlines = + List.from(savedHeadlines) + ..removeWhere((h) => h.id == headline.id); + + final updatedPreferences = + userContentPreferences.copyWith( + savedHeadlines: updatedSavedHeadlines, ); + + context.read().add( + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, ); From f0712bb3c0d448146b6b5c327312fa4adc217128 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 18:26:17 +0100 Subject: [PATCH 37/94] refactor(account): replace AccountBloc with AppBloc in country management - Update import statements to use AppBloc instead of AccountBloc - Modify BlocBuilder to listen to AppBloc instead of AccountBloc - Adjust state accessors to use userContentPreferences instead of preferences - Update country follow/unfollow logic to use AppBloc and AppState - Remove direct dependency on AccountBloc for country management --- .../countries/add_country_to_follow_page.dart | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/lib/account/view/manage_followed_items/countries/add_country_to_follow_page.dart b/lib/account/view/manage_followed_items/countries/add_country_to_follow_page.dart index 96c833dd..82e25607 100644 --- a/lib/account/view/manage_followed_items/countries/add_country_to_follow_page.dart +++ b/lib/account/view/manage_followed_items/countries/add_country_to_follow_page.dart @@ -2,8 +2,8 @@ import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/available_countries_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -57,14 +57,14 @@ class AddCountryToFollowPage extends StatelessWidget { final countries = countriesState.availableCountries; - return BlocBuilder( + return BlocBuilder( buildWhen: (previous, current) => - previous.preferences?.followedCountries != - current.preferences?.followedCountries || - previous.status != current.status, - builder: (context, accountState) { + previous.userContentPreferences?.followedCountries != + current.userContentPreferences?.followedCountries, + builder: (context, appState) { + final userContentPreferences = appState.userContentPreferences; final followedCountries = - accountState.preferences?.followedCountries ?? []; + userContentPreferences?.followedCountries ?? []; return ListView.builder( padding: const EdgeInsets.symmetric( @@ -150,9 +150,27 @@ class AddCountryToFollowPage extends StatelessWidget { ? l10n.unfollowCountryTooltip(country.name) : l10n.followCountryTooltip(country.name), onPressed: () { - context.read().add( - AccountFollowCountryToggled(country: country), + if (userContentPreferences == null) return; + + final updatedFollowedCountries = + List.from(followedCountries); + if (isFollowed) { + updatedFollowedCountries + .removeWhere((c) => c.id == country.id); + } else { + updatedFollowedCountries.add(country); + } + + final updatedPreferences = + userContentPreferences.copyWith( + followedCountries: updatedFollowedCountries, ); + + context.read().add( + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, ), contentPadding: const EdgeInsets.symmetric( From 5b8df188f401bbea01abcc7b43169a86e0e6e61b Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 18:26:35 +0100 Subject: [PATCH 38/94] refactor(account): migrate followed countries page to AppBloc - Replace AccountBloc with AppBloc for state management - Update UI based on AppBloc state and user content preferences - Modify error handling and loading states - Adjust country unfollow functionality to use AppBloc --- .../followed_countries_list_page.dart | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart b/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart index 3cc96337..93a45da7 100644 --- a/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart +++ b/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart @@ -1,7 +1,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; @@ -18,8 +18,6 @@ class FollowedCountriesListPage extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; - final followedCountries = - context.watch().state.preferences?.followedCountries ?? []; return Scaffold( appBar: AppBar( @@ -34,10 +32,13 @@ class FollowedCountriesListPage extends StatelessWidget { ), ], ), - body: BlocBuilder( - builder: (context, state) { - if (state.status == AccountStatus.loading && - state.preferences == null) { + body: BlocBuilder( + builder: (context, appState) { + final user = appState.user; + final userContentPreferences = appState.userContentPreferences; + + if (appState.status == AppLifeCycleStatus.loadingUserData || + userContentPreferences == null) { return LoadingStateWidget( icon: Icons.flag_outlined, headline: l10n.followedCountriesLoadingHeadline, @@ -45,22 +46,19 @@ class FollowedCountriesListPage extends StatelessWidget { ); } - if (state.status == AccountStatus.failure && - state.preferences == null) { + if (appState.initialUserPreferencesError != null) { return FailureStateWidget( - exception: - state.error ?? - OperationFailedException(l10n.followedCountriesErrorHeadline), + exception: appState.initialUserPreferencesError!, onRetry: () { - if (state.user?.id != null) { - context.read().add( - AccountLoadUserPreferences(userId: state.user!.id), - ); - } + context.read().add( + AppStarted(initialUser: user), + ); }, ); } + final followedCountries = userContentPreferences.followedCountries; + if (followedCountries.isEmpty) { return InitialStateWidget( icon: Icons.location_off_outlined, @@ -91,9 +89,20 @@ class FollowedCountriesListPage extends StatelessWidget { ), tooltip: l10n.unfollowCountryTooltip(country.name), onPressed: () { - context.read().add( - AccountFollowCountryToggled(country: country), + final updatedFollowedCountries = + List.from(followedCountries) + ..removeWhere((c) => c.id == country.id); + + final updatedPreferences = + userContentPreferences.copyWith( + followedCountries: updatedFollowedCountries, ); + + context.read().add( + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, ), onTap: () async { From 4336f88b154b73083ad20057bdcd8cd77a63da86 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 18:26:48 +0100 Subject: [PATCH 39/94] refactor(account): move source following functionality to app bloc - Replace AccountBloc with AppBloc for managing followed sources - Update state management to use userContentPreferences instead of preferences - Modify follow/unfollow logic to directly update userContentPreferences - Remove direct dependency on AccountBloc in favor of AppBloc --- .../sources/add_source_to_follow_page.dart | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/lib/account/view/manage_followed_items/sources/add_source_to_follow_page.dart b/lib/account/view/manage_followed_items/sources/add_source_to_follow_page.dart index 99a592ad..9cd3ac40 100644 --- a/lib/account/view/manage_followed_items/sources/add_source_to_follow_page.dart +++ b/lib/account/view/manage_followed_items/sources/add_source_to_follow_page.dart @@ -2,8 +2,8 @@ import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/available_sources_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -47,14 +47,14 @@ class AddSourceToFollowPage extends StatelessWidget { ); } - return BlocBuilder( + return BlocBuilder( buildWhen: (previous, current) => - previous.preferences?.followedSources != - current.preferences?.followedSources || - previous.status != current.status, - builder: (context, accountState) { + previous.userContentPreferences?.followedSources != + current.userContentPreferences?.followedSources, + builder: (context, appState) { + final userContentPreferences = appState.userContentPreferences; final followedSources = - accountState.preferences?.followedSources ?? []; + userContentPreferences?.followedSources ?? []; return ListView.builder( padding: const EdgeInsets.all(AppSpacing.md), @@ -80,9 +80,27 @@ class AddSourceToFollowPage extends StatelessWidget { ? l10n.unfollowSourceTooltip(source.name) : l10n.followSourceTooltip(source.name), onPressed: () { - context.read().add( - AccountFollowSourceToggled(source: source), + if (userContentPreferences == null) return; + + final updatedFollowedSources = + List.from(followedSources); + if (isFollowed) { + updatedFollowedSources + .removeWhere((s) => s.id == source.id); + } else { + updatedFollowedSources.add(source); + } + + final updatedPreferences = + userContentPreferences.copyWith( + followedSources: updatedFollowedSources, ); + + context.read().add( + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, ), ), From 4a27a1f476bf619794cb365dbc317f95ec355386 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 18:27:10 +0100 Subject: [PATCH 40/94] refactor(account): migrate FollowedSourcesListPage to AppBloc - Replace AccountBloc with AppBloc - Update state management and user preferences loading logic - Modify unfollow source functionality to use AppBloc - Improve error handling and state transitions --- .../sources/followed_sources_list_page.dart | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart b/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart index e868f32d..608574cb 100644 --- a/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart +++ b/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart @@ -1,7 +1,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; @@ -18,8 +18,6 @@ class FollowedSourcesListPage extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; - final followedSources = - context.watch().state.preferences?.followedSources ?? []; return Scaffold( appBar: AppBar( @@ -34,10 +32,13 @@ class FollowedSourcesListPage extends StatelessWidget { ), ], ), - body: BlocBuilder( - builder: (context, state) { - if (state.status == AccountStatus.loading && - state.preferences == null) { + body: BlocBuilder( + builder: (context, appState) { + final user = appState.user; + final userContentPreferences = appState.userContentPreferences; + + if (appState.status == AppLifeCycleStatus.loadingUserData || + userContentPreferences == null) { return LoadingStateWidget( icon: Icons.source_outlined, headline: l10n.followedSourcesLoadingHeadline, @@ -45,22 +46,19 @@ class FollowedSourcesListPage extends StatelessWidget { ); } - if (state.status == AccountStatus.failure && - state.preferences == null) { + if (appState.initialUserPreferencesError != null) { return FailureStateWidget( - exception: - state.error ?? - OperationFailedException(l10n.followedSourcesErrorHeadline), + exception: appState.initialUserPreferencesError!, onRetry: () { - if (state.user?.id != null) { - context.read().add( - AccountLoadUserPreferences(userId: state.user!.id), - ); - } + context.read().add( + AppStarted(initialUser: user), + ); }, ); } + final followedSources = userContentPreferences.followedSources; + if (followedSources.isEmpty) { return InitialStateWidget( icon: Icons.no_sim_outlined, @@ -88,9 +86,20 @@ class FollowedSourcesListPage extends StatelessWidget { ), tooltip: l10n.unfollowSourceTooltip(source.name), onPressed: () { - context.read().add( - AccountFollowSourceToggled(source: source), + final updatedFollowedSources = + List.from(followedSources) + ..removeWhere((s) => s.id == source.id); + + final updatedPreferences = + userContentPreferences.copyWith( + followedSources: updatedFollowedSources, ); + + context.read().add( + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, ), onTap: () async { From 079a66ead967860bd34c360ab37f8f4d125d709f Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 18:27:27 +0100 Subject: [PATCH 41/94] refactor(account): replace AccountBloc with AppBloc in topic management - Remove AccountBloc dependency and replace it with AppBloc - Update state management logic for followed topics - Modify UI interactions to use the new AppBloc structure --- .../topics/add_topic_to_follow_page.dart | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/lib/account/view/manage_followed_items/topics/add_topic_to_follow_page.dart b/lib/account/view/manage_followed_items/topics/add_topic_to_follow_page.dart index 601ec94d..dfa14634 100644 --- a/lib/account/view/manage_followed_items/topics/add_topic_to_follow_page.dart +++ b/lib/account/view/manage_followed_items/topics/add_topic_to_follow_page.dart @@ -2,8 +2,8 @@ import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/available_topics_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -57,14 +57,14 @@ class AddTopicToFollowPage extends StatelessWidget { final topics = topicsState.availableTopics; - return BlocBuilder( + return BlocBuilder( buildWhen: (previous, current) => - previous.preferences?.followedTopics != - current.preferences?.followedTopics || - previous.status != current.status, - builder: (context, accountState) { + previous.userContentPreferences?.followedTopics != + current.userContentPreferences?.followedTopics, + builder: (context, appState) { + final userContentPreferences = appState.userContentPreferences; final followedTopics = - accountState.preferences?.followedTopics ?? []; + userContentPreferences?.followedTopics ?? []; return ListView.builder( padding: const EdgeInsets.symmetric( @@ -149,9 +149,27 @@ class AddTopicToFollowPage extends StatelessWidget { ? l10n.unfollowTopicTooltip(topic.name) : l10n.followTopicTooltip(topic.name), onPressed: () { - context.read().add( - AccountFollowTopicToggled(topic: topic), + if (userContentPreferences == null) return; + + final updatedFollowedTopics = + List.from(followedTopics); + if (isFollowed) { + updatedFollowedTopics + .removeWhere((t) => t.id == topic.id); + } else { + updatedFollowedTopics.add(topic); + } + + final updatedPreferences = + userContentPreferences.copyWith( + followedTopics: updatedFollowedTopics, ); + + context.read().add( + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, ), contentPadding: const EdgeInsets.symmetric( From d4a1a724e2a3a76e0107b86ebd75d828390297c1 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 16 Sep 2025 18:27:48 +0100 Subject: [PATCH 42/94] refactor(account): replace AccountBloc with AppBloc in followed topics page - Replace AccountBloc import with AppBloc - Update state management from AccountState to AppState - Modify loading and error handling logic - Update followed topics retrieval and unfollo --- .../topics/followed_topics_list_page.dart | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart b/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart index 9e5fca02..ff5df203 100644 --- a/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart +++ b/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart @@ -1,7 +1,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; @@ -18,8 +18,6 @@ class FollowedTopicsListPage extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; - final followedTopics = - context.watch().state.preferences?.followedTopics ?? []; return Scaffold( appBar: AppBar( @@ -34,10 +32,13 @@ class FollowedTopicsListPage extends StatelessWidget { ), ], ), - body: BlocBuilder( - builder: (context, state) { - if (state.status == AccountStatus.loading && - state.preferences == null) { + body: BlocBuilder( + builder: (context, appState) { + final user = appState.user; + final userContentPreferences = appState.userContentPreferences; + + if (appState.status == AppLifeCycleStatus.loadingUserData || + userContentPreferences == null) { return LoadingStateWidget( icon: Icons.topic_outlined, headline: l10n.followedTopicsLoadingHeadline, @@ -45,22 +46,19 @@ class FollowedTopicsListPage extends StatelessWidget { ); } - if (state.status == AccountStatus.failure && - state.preferences == null) { + if (appState.initialUserPreferencesError != null) { return FailureStateWidget( - exception: - state.error ?? - OperationFailedException(l10n.followedTopicsErrorHeadline), + exception: appState.initialUserPreferencesError!, onRetry: () { - if (state.user?.id != null) { - context.read().add( - AccountLoadUserPreferences(userId: state.user!.id), - ); - } + context.read().add( + AppStarted(initialUser: user), + ); }, ); } + final followedTopics = userContentPreferences.followedTopics; + if (followedTopics.isEmpty) { return InitialStateWidget( icon: Icons.no_sim_outlined, @@ -96,9 +94,20 @@ class FollowedTopicsListPage extends StatelessWidget { ), tooltip: l10n.unfollowTopicTooltip(topic.name), onPressed: () { - context.read().add( - AccountFollowTopicToggled(topic: topic), + final updatedFollowedTopics = + List.from(followedTopics) + ..removeWhere((t) => t.id == topic.id); + + final updatedPreferences = + userContentPreferences.copyWith( + followedTopics: updatedFollowedTopics, ); + + context.read().add( + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, ), onTap: () async { From 06ed630f062d8bd5a944b6496a38a2c49ee36222 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 07:14:20 +0100 Subject: [PATCH 43/94] refactor(ads): use simplified theme data in interstitial ad manager - Replace individual settings with aggregated theme properties - Simplify lightTheme and darkTheme creation by using shared properties --- lib/ads/interstitial_ad_manager.dart | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/ads/interstitial_ad_manager.dart b/lib/ads/interstitial_ad_manager.dart index 8faa44d7..3ad7fdc4 100644 --- a/lib/ads/interstitial_ad_manager.dart +++ b/lib/ads/interstitial_ad_manager.dart @@ -108,17 +108,15 @@ class InterstitialAdManager { final themeData = brightness == Brightness.light ? lightTheme( scheme: appState.flexScheme, - appTextScaleFactor: - appState.settings.displaySettings.textScaleFactor, - appFontWeight: appState.settings.displaySettings.fontWeight, - fontFamily: appState.settings.displaySettings.fontFamily, + appTextScaleFactor: appState.appTextScaleFactor, + appFontWeight: appState.appFontWeight, + fontFamily: appState.fontFamily, ) : darkTheme( scheme: appState.flexScheme, - appTextScaleFactor: - appState.settings.displaySettings.textScaleFactor, - appFontWeight: appState.settings.displaySettings.fontWeight, - fontFamily: appState.settings.displaySettings.fontFamily, + appTextScaleFactor: appState.appTextScaleFactor, + appFontWeight: appState.appFontWeight, + fontFamily: appState.fontFamily, ); final adThemeStyle = AdThemeStyle.fromTheme(themeData); From 721e1777cb00666e93f1b3af8a85625ad82c15b1 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 07:16:03 +0100 Subject: [PATCH 44/94] refactor(ads): simplify headline image style retrieval - Update feed_ad_loader_widget.dart to use a simplified method for obtaining the current HeadlineImageStyle from AppBloc - Remove unnecessary nested state access for feedPreferences --- lib/ads/widgets/feed_ad_loader_widget.dart | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/lib/ads/widgets/feed_ad_loader_widget.dart b/lib/ads/widgets/feed_ad_loader_widget.dart index d4d28bf2..be7d5f75 100644 --- a/lib/ads/widgets/feed_ad_loader_widget.dart +++ b/lib/ads/widgets/feed_ad_loader_widget.dart @@ -204,12 +204,7 @@ class _FeedAdLoaderWidgetState extends State { } // Get the current HeadlineImageStyle from AppBloc - final headlineImageStyle = context - .read() - .state - .settings - .feedPreferences - .headlineImageStyle; + final headlineImageStyle = context.read().state.headlineImageStyle; // Call AdService.getFeedAd with the full AdConfig and adType from the placeholder. final loadedAd = await _adService.getFeedAd( @@ -279,12 +274,7 @@ class _FeedAdLoaderWidgetState extends State { Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); - final headlineImageStyle = context - .read() - .state - .settings - .feedPreferences - .headlineImageStyle; + final headlineImageStyle = context.read().state.headlineImageStyle; if (_isLoading || _hasError || _loadedAd == null) { // Show a user-friendly message when loading, on error, or if no ad is loaded. From 86c08e4548f277b13372061f6eb0cfa738350316 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 07:16:13 +0100 Subject: [PATCH 45/94] feat(app_state): add headlineImageStyle property - Add a new getter for headlineImageStyle in AppState class - Defaults to HeadlineImageStyle.smallThumbnail if settings are not loaded - This change allows for easier access to the current headline image style setting --- lib/app/bloc/app_state.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index 332ee55b..7bbf624e 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -128,6 +128,13 @@ class AppState extends Equatable { return settings?.displaySettings.fontWeight ?? AppFontWeight.regular; } + /// The current headline image style, derived from [settings]. + /// Defaults to [HeadlineImageStyle.smallThumbnail] if [settings] are not yet loaded. + HeadlineImageStyle get headlineImageStyle { + return settings?.feedPreferences.headlineImageStyle ?? + HeadlineImageStyle.smallThumbnail; + } + /// The currently selected locale for localization, derived from [settings]. /// Defaults to English ('en') if [settings] are not yet loaded. Locale get locale { From 0e2a81ea74a8949226b108e7f240b01b1a6a24e5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 07:16:26 +0100 Subject: [PATCH 46/94] perf(ads): optimize ad loader widget performance - Replace complex state access with direct headlineImageStyle access - Reduce unnecessary nested state reads in the build method --- lib/ads/widgets/in_article_ad_loader_widget.dart | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/ads/widgets/in_article_ad_loader_widget.dart b/lib/ads/widgets/in_article_ad_loader_widget.dart index 090dd507..21597c87 100644 --- a/lib/ads/widgets/in_article_ad_loader_widget.dart +++ b/lib/ads/widgets/in_article_ad_loader_widget.dart @@ -247,12 +247,7 @@ class _InArticleAdLoaderWidgetState extends State { Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); - final headlineImageStyle = context - .read() - .state - .settings - .feedPreferences - .headlineImageStyle; + final headlineImageStyle = context.read().state.headlineImageStyle; if (_isLoading || _hasError || _loadedAd == null) { // Show a user-friendly message when loading, on error, or if no ad is loaded. From 7d444f225cd47fdc6e8c89c3250a1959d895ab6d Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 07:16:41 +0100 Subject: [PATCH 47/94] feat(theme): make text scaling, font weight, and family configurable - Replace hardcoded theme settings with state variables for appTextScaleFactor, appFontWeight, and fontFamily - Update theme configurations in multiple places to use dynamic state values instead of static defaults --- lib/app/view/app.dart | 48 +++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index b1bb8851..e01b483f 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -296,15 +296,15 @@ class _AppViewState extends State<_AppView> { debugShowCheckedModeBanner: false, theme: lightTheme( scheme: FlexScheme.material, - appTextScaleFactor: AppTextScaleFactor.medium, - appFontWeight: AppFontWeight.regular, - fontFamily: null, + appTextScaleFactor: state.appTextScaleFactor, + appFontWeight: state.appFontWeight, + fontFamily: state.fontFamily, ), darkTheme: darkTheme( scheme: FlexScheme.material, - appTextScaleFactor: AppTextScaleFactor.medium, - appFontWeight: AppFontWeight.regular, - fontFamily: null, + appTextScaleFactor: state.appTextScaleFactor, + appFontWeight: state.appFontWeight, + fontFamily: state.fontFamily, ), themeMode: state.themeMode, localizationsDelegates: const [ @@ -363,15 +363,15 @@ class _AppViewState extends State<_AppView> { debugShowCheckedModeBanner: false, theme: lightTheme( scheme: FlexScheme.material, - appTextScaleFactor: AppTextScaleFactor.medium, - appFontWeight: AppFontWeight.regular, - fontFamily: null, + appTextScaleFactor: state.appTextScaleFactor, + appFontWeight: state.appFontWeight, + fontFamily: state.fontFamily, ), darkTheme: darkTheme( scheme: FlexScheme.material, - appTextScaleFactor: AppTextScaleFactor.medium, - appFontWeight: AppFontWeight.regular, - fontFamily: null, + appTextScaleFactor: state.appTextScaleFactor, + appFontWeight: state.appFontWeight, + fontFamily: state.fontFamily, ), themeMode: state.themeMode, localizationsDelegates: const [ @@ -390,15 +390,15 @@ class _AppViewState extends State<_AppView> { debugShowCheckedModeBanner: false, theme: lightTheme( scheme: FlexScheme.material, - appTextScaleFactor: AppTextScaleFactor.medium, - appFontWeight: AppFontWeight.regular, - fontFamily: null, + appTextScaleFactor: state.appTextScaleFactor, + appFontWeight: state.appFontWeight, + fontFamily: state.fontFamily, ), darkTheme: darkTheme( scheme: FlexScheme.material, - appTextScaleFactor: AppTextScaleFactor.medium, - appFontWeight: AppFontWeight.regular, - fontFamily: null, + appTextScaleFactor: state.appTextScaleFactor, + appFontWeight: state.appFontWeight, + fontFamily: state.fontFamily, ), themeMode: state.themeMode, localizationsDelegates: const [ @@ -420,15 +420,15 @@ class _AppViewState extends State<_AppView> { debugShowCheckedModeBanner: false, theme: lightTheme( scheme: FlexScheme.material, - appTextScaleFactor: AppTextScaleFactor.medium, - appFontWeight: AppFontWeight.regular, - fontFamily: null, + appTextScaleFactor: state.appTextScaleFactor, + appFontWeight: state.appFontWeight, + fontFamily: state.fontFamily, ), darkTheme: darkTheme( scheme: FlexScheme.material, - appTextScaleFactor: AppTextScaleFactor.medium, - appFontWeight: AppFontWeight.regular, - fontFamily: null, + appTextScaleFactor: state.appTextScaleFactor, + appFontWeight: state.appFontWeight, + fontFamily: state.fontFamily, ), themeMode: state.themeMode, localizationsDelegates: const [ From 355c0a03da0be09e2b0b63ed1116db9b3ab51872 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 07:23:47 +0100 Subject: [PATCH 48/94] refactor(entity_details): remove unused user preferences event - Remove unused _EntityDetailsUserPreferencesChanged event from entity details BLoC - This event was intended to notify the BLoC about content preferences changes but is no longer needed --- lib/entity_details/bloc/entity_details_event.dart | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/lib/entity_details/bloc/entity_details_event.dart b/lib/entity_details/bloc/entity_details_event.dart index 38b9237f..20e84917 100644 --- a/lib/entity_details/bloc/entity_details_event.dart +++ b/lib/entity_details/bloc/entity_details_event.dart @@ -47,15 +47,3 @@ class EntityDetailsLoadMoreHeadlinesRequested extends EntityDetailsEvent { @override List get props => [adThemeStyle]; } - -/// Internal event to notify the BLoC that the user's content preferences -/// have changed elsewhere in the app. -class _EntityDetailsUserPreferencesChanged extends EntityDetailsEvent { - const _EntityDetailsUserPreferencesChanged(this.preferences); - - /// The updated user content preferences. - final UserContentPreferences preferences; - - @override - List get props => [preferences]; -} From b6366f376ed11280c738cab93a45d5564c3db80e Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 07:24:54 +0100 Subject: [PATCH 49/94] refactor(entity-details): remove AccountBloc and streamline user preferences - Remove AccountBloc dependency from EntityDetailsBloc - Update user preferences handling to use AppBloc instead - Simplify follow/unfollow functionality - Remove unnecessary stream subscription - Update documentation and comments --- .../bloc/entity_details_bloc.dart | 141 ++++++++++-------- 1 file changed, 76 insertions(+), 65 deletions(-) diff --git a/lib/entity_details/bloc/entity_details_bloc.dart b/lib/entity_details/bloc/entity_details_bloc.dart index 8357fc9c..1d934a85 100644 --- a/lib/entity_details/bloc/entity_details_bloc.dart +++ b/lib/entity_details/bloc/entity_details_bloc.dart @@ -6,7 +6,6 @@ import 'package:bloc/bloc.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/inline_ad_cache_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; @@ -15,13 +14,21 @@ import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/ part 'entity_details_event.dart'; part 'entity_details_state.dart'; +/// {@template entity_details_bloc} +/// Manages the state for the entity details feature. +/// +/// This BLoC is responsible for fetching the details of a specific entity +/// (Topic, Source, or Country) and its associated headlines. It also handles +/// toggling the "follow" status of the entity by dispatching events to the +/// [AppBloc], which is the single source of truth for user preferences. +/// {@endtemplate} class EntityDetailsBloc extends Bloc { + /// {@macro entity_details_bloc} EntityDetailsBloc({ required DataRepository headlinesRepository, required DataRepository topicRepository, required DataRepository sourceRepository, required DataRepository countryRepository, - required AccountBloc accountBloc, required AppBloc appBloc, required FeedDecoratorService feedDecoratorService, required InlineAdCacheService inlineAdCacheService, @@ -29,7 +36,6 @@ class EntityDetailsBloc extends Bloc { _topicRepository = topicRepository, _sourceRepository = sourceRepository, _countryRepository = countryRepository, - _accountBloc = accountBloc, _appBloc = appBloc, _feedDecoratorService = feedDecoratorService, _inlineAdCacheService = inlineAdCacheService, @@ -41,30 +47,21 @@ class EntityDetailsBloc extends Bloc { on( _onEntityDetailsLoadMoreHeadlinesRequested, ); - on<_EntityDetailsUserPreferencesChanged>( - _onEntityDetailsUserPreferencesChanged, - ); - - // Listen to AccountBloc for changes in user preferences - _accountBlocSubscription = _accountBloc.stream.listen((accountState) { - if (accountState.preferences != null) { - add(_EntityDetailsUserPreferencesChanged(accountState.preferences!)); - } - }); } final DataRepository _headlinesRepository; final DataRepository _topicRepository; final DataRepository _sourceRepository; final DataRepository _countryRepository; - final AccountBloc _accountBloc; final AppBloc _appBloc; final FeedDecoratorService _feedDecoratorService; final InlineAdCacheService _inlineAdCacheService; - late final StreamSubscription _accountBlocSubscription; static const _headlinesLimit = 10; + /// Handles the [EntityDetailsLoadRequested] event. + /// + /// Fetches the entity details and its initial set of headlines. Future _onEntityDetailsLoadRequested( EntityDetailsLoadRequested event, Emitter emit, @@ -125,19 +122,17 @@ class EntityDetailsBloc extends Bloc { // This method injects stateless `AdPlaceholder` markers into the feed. // The full ad loading and lifecycle is managed by the UI layer. // See `FeedDecoratorService` for a detailed explanation. - final processedFeedItems = await _feedDecoratorService - .injectAdPlaceholders( - feedItems: headlineResponse.items, - user: currentUser, - adConfig: remoteConfig.adConfig, - imageStyle: - _appBloc.state.settings.feedPreferences.headlineImageStyle, - adThemeStyle: event.adThemeStyle, - ); + final processedFeedItems = await _feedDecoratorService.injectAdPlaceholders( + feedItems: headlineResponse.items, + user: currentUser, + adConfig: remoteConfig.adConfig, + imageStyle: _appBloc.state.headlineImageStyle, + adThemeStyle: event.adThemeStyle, + ); - // 3. Determine isFollowing status + // 3. Determine isFollowing status from AppBloc's user preferences var isCurrentlyFollowing = false; - final preferences = _accountBloc.state.preferences; + final preferences = _appBloc.state.userContentPreferences; if (preferences != null) { if (entityToLoad is Topic) { isCurrentlyFollowing = preferences.followedTopics.any( @@ -180,22 +175,72 @@ class EntityDetailsBloc extends Bloc { } } + /// Handles the [EntityDetailsToggleFollowRequested] event. + /// + /// Dispatches an [AppUserContentPreferencesChanged] event to the [AppBloc] + /// to update the user's followed entities. Future _onEntityDetailsToggleFollowRequested( EntityDetailsToggleFollowRequested event, Emitter emit, ) async { final entity = state.entity; - if (entity == null) return; + final currentUser = _appBloc.state.user; + var currentPreferences = _appBloc.state.userContentPreferences; + + if (entity == null || currentUser == null || currentPreferences == null) { + return; + } + + // Create a mutable copy of the lists to modify + final updatedFollowedTopics = List.from(currentPreferences.followedTopics); + final updatedFollowedSources = List.from(currentPreferences.followedSources); + final updatedFollowedCountries = List.from(currentPreferences.followedCountries); + + var isCurrentlyFollowing = false; if (entity is Topic) { - _accountBloc.add(AccountFollowTopicToggled(topic: entity)); + final topic = entity; + if (updatedFollowedTopics.any((t) => t.id == topic.id)) { + updatedFollowedTopics.removeWhere((t) => t.id == topic.id); + } else { + updatedFollowedTopics.add(topic); + isCurrentlyFollowing = true; + } } else if (entity is Source) { - _accountBloc.add(AccountFollowSourceToggled(source: entity)); + final source = entity; + if (updatedFollowedSources.any((s) => s.id == source.id)) { + updatedFollowedSources.removeWhere((s) => s.id == source.id); + } else { + updatedFollowedSources.add(source); + isCurrentlyFollowing = true; + } } else if (entity is Country) { - _accountBloc.add(AccountFollowCountryToggled(country: entity)); + final country = entity; + if (updatedFollowedCountries.any((c) => c.id == country.id)) { + updatedFollowedCountries.removeWhere((c) => c.id == country.id); + } else { + updatedFollowedCountries.add(country); + isCurrentlyFollowing = true; + } } + + // Create a new UserContentPreferences object with the updated lists + final newPreferences = currentPreferences.copyWith( + followedTopics: updatedFollowedTopics, + followedSources: updatedFollowedSources, + followedCountries: updatedFollowedCountries, + ); + + // Dispatch the event to AppBloc to update and persist preferences + _appBloc.add(AppUserContentPreferencesChanged(preferences: newPreferences)); + + // Optimistically update local state + emit(state.copyWith(isFollowing: isCurrentlyFollowing)); } + /// Handles the [EntityDetailsLoadMoreHeadlinesRequested] event. + /// + /// Fetches the next page of headlines for the current entity. Future _onEntityDetailsLoadMoreHeadlinesRequested( EntityDetailsLoadMoreHeadlinesRequested event, Emitter emit, @@ -245,7 +290,7 @@ class EntityDetailsBloc extends Bloc { feedItems: headlineResponse.items, user: currentUser, adConfig: remoteConfig.adConfig, - imageStyle: _appBloc.state.settings.feedPreferences.headlineImageStyle, + imageStyle: _appBloc.state.headlineImageStyle, // Use the AdThemeStyle passed directly from the UI via the event. // This ensures that ads are styled consistently with the current, // fully-resolved theme of the widget, preventing visual discrepancies. @@ -282,38 +327,4 @@ class EntityDetailsBloc extends Bloc { } } - void _onEntityDetailsUserPreferencesChanged( - _EntityDetailsUserPreferencesChanged event, - Emitter emit, - ) { - final entity = state.entity; - if (entity == null) return; - - var isCurrentlyFollowing = false; - final preferences = event.preferences; - - if (entity is Topic) { - isCurrentlyFollowing = preferences.followedTopics.any( - (t) => t.id == entity.id, - ); - } else if (entity is Source) { - isCurrentlyFollowing = preferences.followedSources.any( - (s) => s.id == entity.id, - ); - } else if (entity is Country) { - isCurrentlyFollowing = preferences.followedCountries.any( - (c) => c.id == entity.id, - ); - } - - if (state.isFollowing != isCurrentlyFollowing) { - emit(state.copyWith(isFollowing: isCurrentlyFollowing)); - } - } - - @override - Future close() { - _accountBlocSubscription.cancel(); - return super.close(); - } } From 25160dd5cf43f417860061491a12f6d274b8d3ea Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 07:25:12 +0100 Subject: [PATCH 50/94] refactor(entity_details): optimize AppBloc state access - Replace multiple context.watch() calls with context.read() for better performance - Directly access headlineImageStyle and adConfig to simplify code and reduce redundancy --- lib/entity_details/view/entity_details_page.dart | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index 44ec0f2c..bb84418e 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -325,12 +325,8 @@ class _EntityDetailsViewState extends State { final item = state.feedItems[index]; if (item is Headline) { - final imageStyle = context - .watch() - .state - .settings - .feedPreferences - .headlineImageStyle; + final imageStyle = + context.read().state.headlineImageStyle; Widget tile; switch (imageStyle) { case HeadlineImageStyle.hidden: @@ -354,11 +350,8 @@ class _EntityDetailsViewState extends State { // Retrieve the user's preferred headline image style from the AppBloc. // This is the single source of truth for this setting. // Access the AppBloc to get the remoteConfig for ads. - final adConfig = context - .read() - .state - .remoteConfig - ?.adConfig; + final adConfig = + context.read().state.remoteConfig?.adConfig; // Ensure adConfig is not null before building the AdLoaderWidget. if (adConfig == null) { From 556455bc73c043338160d8634007e324a626f90a Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 07:25:30 +0100 Subject: [PATCH 51/94] refactor(router): remove shared AccountBloc instance - Remove the instantiation of AccountBloc in the createRouter function - Remove the accountBloc parameter from the AccountPage --- lib/router/router.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index 9d79be3d..27fce961 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -3,7 +3,6 @@ import 'package:core/core.dart' hide AppStatus; import 'package:data_repository/data_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/view/account_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/view/manage_followed_items/countries/add_country_to_follow_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/view/manage_followed_items/countries/followed_countries_list_page.dart'; @@ -71,10 +70,6 @@ GoRouter createRouter({ required GlobalKey navigatorKey, required InlineAdCacheService inlineAdCacheService, }) { - // Instantiate AccountBloc once to be shared - final accountBloc = AccountBloc( - authenticationRepository: authenticationRepository, - ); // Instantiate FeedDecoratorService once to be shared final feedDecoratorService = FeedDecoratorService( @@ -292,7 +287,6 @@ GoRouter createRouter({ sourceRepository: context.read>(), countryRepository: context .read>(), - accountBloc: accountBloc, appBloc: context.read(), feedDecoratorService: feedDecoratorService, inlineAdCacheService: inlineAdCacheService, From 0b3a971b09b674c3cd3765c8f46d68c0df0e2762 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 07:42:12 +0100 Subject: [PATCH 52/94] style: format misc --- .../countries/followed_countries_list_page.dart | 2 +- .../sources/followed_sources_list_page.dart | 2 +- .../manage_followed_items/topics/followed_topics_list_page.dart | 2 +- lib/entity_details/bloc/entity_details_bloc.dart | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart b/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart index 93a45da7..87a8effe 100644 --- a/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart +++ b/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart @@ -1,8 +1,8 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart b/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart index 608574cb..56e0a8ed 100644 --- a/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart +++ b/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart @@ -1,8 +1,8 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart b/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart index ff5df203..62aa862f 100644 --- a/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart +++ b/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart @@ -1,8 +1,8 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/entity_details/bloc/entity_details_bloc.dart b/lib/entity_details/bloc/entity_details_bloc.dart index 1d934a85..f687c6c4 100644 --- a/lib/entity_details/bloc/entity_details_bloc.dart +++ b/lib/entity_details/bloc/entity_details_bloc.dart @@ -185,7 +185,7 @@ class EntityDetailsBloc extends Bloc { ) async { final entity = state.entity; final currentUser = _appBloc.state.user; - var currentPreferences = _appBloc.state.userContentPreferences; + final currentPreferences = _appBloc.state.userContentPreferences; if (entity == null || currentUser == null || currentPreferences == null) { return; From 61949e3e2c036373d64c0ca49441fa67977f0cdb Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 07:42:42 +0100 Subject: [PATCH 53/94] refactor(headline-details): replace AccountBloc with AppBloc for state management - Remove AccountBloc dependencies and related imports - Update state listening and handling to use AppBloc instead of AccountBloc - Modify saved headlines logic to use userContentPreferences from AppBloc - Adjust error handling for headline saving functionality --- .../view/headline_details_page.dart | 74 ++++++++++++------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/lib/headline-details/view/headline_details_page.dart b/lib/headline-details/view/headline_details_page.dart index 9fa71f2c..3524ca57 100644 --- a/lib/headline-details/view/headline_details_page.dart +++ b/lib/headline-details/view/headline_details_page.dart @@ -6,7 +6,6 @@ import 'package:core/core.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/widgets/in_article_ad_loader_widget.dart'; @@ -67,42 +66,33 @@ class _HeadlineDetailsPageState extends State { }, child: SafeArea( child: Scaffold( - body: BlocListener( + body: BlocListener( listenWhen: (previous, current) { final detailsState = context.read().state; if (detailsState is HeadlineDetailsLoaded) { final currentHeadlineId = detailsState.headline.id; final wasPreviouslySaved = - previous.preferences?.savedHeadlines.any( + previous.userContentPreferences?.savedHeadlines.any( (h) => h.id == currentHeadlineId, ) ?? false; final isCurrentlySaved = - current.preferences?.savedHeadlines.any( + current.userContentPreferences?.savedHeadlines.any( (h) => h.id == currentHeadlineId, ) ?? false; - if (wasPreviouslySaved != isCurrentlySaved) { - return current.status == AccountStatus.success || - current.status == AccountStatus.failure; - } - if (current.status == AccountStatus.failure && - previous.status == AccountStatus.loading) { - return true; - } + + // Listen for changes in saved status or errors during persistence + return (wasPreviouslySaved != isCurrentlySaved) || + (current.initialUserPreferencesError != null && + previous.initialUserPreferencesError == null); } return false; }, - listener: (context, accountState) { + listener: (context, appState) { final detailsState = context.read().state; if (detailsState is HeadlineDetailsLoaded) { - final nowIsSaved = - accountState.preferences?.savedHeadlines.any( - (h) => h.id == detailsState.headline.id, - ) ?? - false; - if (accountState.status == AccountStatus.failure && - accountState.error != null) { + if (appState.initialUserPreferencesError != null) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( @@ -112,6 +102,11 @@ class _HeadlineDetailsPageState extends State { ), ); } else { + final nowIsSaved = + appState.userContentPreferences?.savedHeadlines.any( + (h) => h.id == detailsState.headline.id, + ) ?? + false; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( @@ -174,9 +169,9 @@ class _HeadlineDetailsPageState extends State { horizontal: AppSpacing.paddingLarge, ); - final accountState = context.watch().state; + final appBlocState = context.watch().state; final isSaved = - accountState.preferences?.savedHeadlines.any( + appBlocState.userContentPreferences?.savedHeadlines.any( (h) => h.id == headline.id, ) ?? false; @@ -190,8 +185,36 @@ class _HeadlineDetailsPageState extends State { ? l10n.headlineDetailsRemoveFromSavedTooltip : l10n.headlineDetailsSaveTooltip, onPressed: () { - context.read().add( - AccountSaveHeadlineToggled(headline: headline), + final currentPreferences = appBlocState.userContentPreferences; + if (currentPreferences == null) { + // Handle case where preferences are not loaded (e.g., show error) + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(l10n.headlineSaveErrorSnackbar), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + return; + } + + final List updatedSavedHeadlines; + if (isSaved) { + updatedSavedHeadlines = currentPreferences.savedHeadlines + .where((h) => h.id != headline.id) + .toList(); + } else { + updatedSavedHeadlines = List.of(currentPreferences.savedHeadlines) + ..add(headline); + } + + final updatedPreferences = currentPreferences.copyWith( + savedHeadlines: updatedSavedHeadlines, + ); + + context.read().add( + AppUserContentPreferencesChanged(preferences: updatedPreferences), ); }, ); @@ -240,7 +263,6 @@ class _HeadlineDetailsPageState extends State { }, ); - final appBlocState = context.watch().state; final adConfig = appBlocState.remoteConfig?.adConfig; final adThemeStyle = AdThemeStyle.fromTheme(Theme.of(context)); @@ -663,8 +685,6 @@ class _HeadlineDetailsPageState extends State { final imageStyle = context .watch() .state - .settings - .feedPreferences .headlineImageStyle; Widget tile; switch (imageStyle) { From da31af89e1dc751d4f92f04d0f0472ef5c70d4fa Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 07:43:14 +0100 Subject: [PATCH 54/94] lint: misc --- lib/app/view/app.dart | 21 ++------------------- lib/router/router.dart | 1 - 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index e01b483f..61453674 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -30,25 +30,8 @@ import 'package:ui_kit/ui_kit.dart'; /// {@endtemplate} class App extends StatelessWidget { /// {@macro app_widget} - const App({ - required AuthRepository authenticationRepository, - required DataRepository headlinesRepository, - required DataRepository topicsRepository, - required DataRepository countriesRepository, - required DataRepository sourcesRepository, - required DataRepository userAppSettingsRepository, - required DataRepository - userContentPreferencesRepository, - required DataRepository remoteConfigRepository, - required DataRepository userRepository, - required KVStorageService kvStorageService, - required AppEnvironment environment, - required AdService adService, - required DataRepository localAdRepository, - required GlobalKey navigatorKey, - required InlineAdCacheService inlineAdCacheService, - required RemoteConfig? initialRemoteConfig, - required HttpException? initialRemoteConfigError, + const App({required AuthRepository authenticationRepository, required DataRepository headlinesRepository, required DataRepository topicsRepository, required DataRepository countriesRepository, required DataRepository sourcesRepository, required DataRepository userAppSettingsRepository, required DataRepository + userContentPreferencesRepository, required DataRepository remoteConfigRepository, required DataRepository userRepository, required KVStorageService kvStorageService, required AppEnvironment environment, required AdService adService, required DataRepository localAdRepository, required GlobalKey navigatorKey, required InlineAdCacheService inlineAdCacheService, required RemoteConfig? initialRemoteConfig, required HttpException? initialRemoteConfigError, super.key, this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, diff --git a/lib/router/router.dart b/lib/router/router.dart index 27fce961..82bc8f68 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -70,7 +70,6 @@ GoRouter createRouter({ required GlobalKey navigatorKey, required InlineAdCacheService inlineAdCacheService, }) { - // Instantiate FeedDecoratorService once to be shared final feedDecoratorService = FeedDecoratorService( topicsRepository: topicsRepository, From bc58d36b8fe19d117cf41a211c20598aad6d114a Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 08:39:05 +0100 Subject: [PATCH 55/94] refactor(headlines-feed): remove unused countries filter logic - Remove CountriesFilterApplyFollowedRequested event handler - Remove related repositories and state fields - Simplify class constructor --- .../bloc/countries_filter_bloc.dart | 61 ------------------- 1 file changed, 61 deletions(-) diff --git a/lib/headlines-feed/bloc/countries_filter_bloc.dart b/lib/headlines-feed/bloc/countries_filter_bloc.dart index 5eb1ea63..fc1a0181 100644 --- a/lib/headlines-feed/bloc/countries_filter_bloc.dart +++ b/lib/headlines-feed/bloc/countries_filter_bloc.dart @@ -24,26 +24,17 @@ class CountriesFilterBloc /// Requires a [DataRepository] to interact with the data layer. CountriesFilterBloc({ required DataRepository countriesRepository, - required DataRepository - userContentPreferencesRepository, // Inject UserContentPreferencesRepository required AppBloc appBloc, // Inject AppBloc }) : _countriesRepository = countriesRepository, - _userContentPreferencesRepository = userContentPreferencesRepository, _appBloc = appBloc, super(const CountriesFilterState()) { on( _onCountriesFilterRequested, transformer: restartable(), ); - on( - _onCountriesFilterApplyFollowedRequested, - transformer: restartable(), - ); } final DataRepository _countriesRepository; - final DataRepository - _userContentPreferencesRepository; final AppBloc _appBloc; /// Handles the request to fetch countries based on a specific usage. @@ -89,56 +80,4 @@ class CountriesFilterBloc emit(state.copyWith(status: CountriesFilterStatus.failure, error: e)); } } - - /// Handles the request to apply the user's followed countries as filters. - Future _onCountriesFilterApplyFollowedRequested( - CountriesFilterApplyFollowedRequested event, - Emitter emit, - ) async { - emit( - state.copyWith(followedCountriesStatus: CountriesFilterStatus.loading), - ); - - final currentUser = _appBloc.state.user!; - - try { - final preferences = await _userContentPreferencesRepository.read( - id: currentUser.id, - userId: currentUser.id, - ); - - if (preferences.followedCountries.isEmpty) { - emit( - state.copyWith( - followedCountriesStatus: CountriesFilterStatus.success, - followedCountries: const [], - clearError: true, - ), - ); - return; - } - - emit( - state.copyWith( - followedCountriesStatus: CountriesFilterStatus.success, - followedCountries: preferences.followedCountries, - clearFollowedCountriesError: true, - ), - ); - } on HttpException catch (e) { - emit( - state.copyWith( - followedCountriesStatus: CountriesFilterStatus.failure, - error: e, - ), - ); - } catch (e) { - emit( - state.copyWith( - followedCountriesStatus: CountriesFilterStatus.failure, - error: UnknownException(e.toString()), - ), - ); - } - } } From 3ac09f332e6dd5c0d8ab5d4e930386dbff827863 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 08:39:32 +0100 Subject: [PATCH 56/94] refactor(headlines-feed): remove followed countries from filter state - Remove followedCountries and followedCountriesStatus from CountriesFilterState - Remove unnecessary imports and comments related to followed countries - Update copyWith method to reflect removed properties --- .../bloc/countries_filter_state.dart | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/lib/headlines-feed/bloc/countries_filter_state.dart b/lib/headlines-feed/bloc/countries_filter_state.dart index 74143648..ae6c0c1a 100644 --- a/lib/headlines-feed/bloc/countries_filter_state.dart +++ b/lib/headlines-feed/bloc/countries_filter_state.dart @@ -1,7 +1,5 @@ part of 'countries_filter_bloc.dart'; -// Import removed, will be added to the main bloc file. - /// Enum representing the different statuses of the country filter data fetching. enum CountriesFilterStatus { /// Initial state, no data loaded yet. @@ -34,8 +32,6 @@ final class CountriesFilterState extends Equatable { this.hasMore = true, this.cursor, this.error, - this.followedCountriesStatus = CountriesFilterStatus.initial, - this.followedCountries = const [], }); /// The current status of fetching countries. @@ -54,12 +50,6 @@ final class CountriesFilterState extends Equatable { /// An optional error object if the status is [CountriesFilterStatus.failure]. final HttpException? error; - /// The current status of fetching followed countries. - final CountriesFilterStatus followedCountriesStatus; - - /// The list of [Country] objects representing the user's followed countries. - final List followedCountries; - /// Creates a copy of this state with the given fields replaced. CountriesFilterState copyWith({ CountriesFilterStatus? status, @@ -67,11 +57,8 @@ final class CountriesFilterState extends Equatable { bool? hasMore, String? cursor, HttpException? error, - CountriesFilterStatus? followedCountriesStatus, - List? followedCountries, bool clearError = false, bool clearCursor = false, - bool clearFollowedCountriesError = false, }) { return CountriesFilterState( status: status ?? this.status, @@ -81,9 +68,6 @@ final class CountriesFilterState extends Equatable { cursor: clearCursor ? null : (cursor ?? this.cursor), // Clear error if requested, otherwise keep existing or use new one error: clearError ? null : error ?? this.error, - followedCountriesStatus: - followedCountriesStatus ?? this.followedCountriesStatus, - followedCountries: followedCountries ?? this.followedCountries, ); } @@ -94,7 +78,5 @@ final class CountriesFilterState extends Equatable { hasMore, cursor, error, - followedCountriesStatus, - followedCountries, ]; } From d3546c5633e3094a287634add3a010ce890eb4d4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 08:40:23 +0100 Subject: [PATCH 57/94] refactor(headlines-feed): simplify user preferences retrieval and state handling - Remove direct dependency on UserContentPreferences repository - Use userContentPreferences from AppBloc state instead of fetching separately - Update imageStyle retrieval to handle potential null state --- .../bloc/headlines_feed_bloc.dart | 43 +++++-------------- 1 file changed, 10 insertions(+), 33 deletions(-) diff --git a/lib/headlines-feed/bloc/headlines_feed_bloc.dart b/lib/headlines-feed/bloc/headlines_feed_bloc.dart index 061bdd4d..80e5b64c 100644 --- a/lib/headlines-feed/bloc/headlines_feed_bloc.dart +++ b/lib/headlines-feed/bloc/headlines_feed_bloc.dart @@ -27,13 +27,10 @@ class HeadlinesFeedBloc extends Bloc { /// Requires repositories and services for its operations. HeadlinesFeedBloc({ required DataRepository headlinesRepository, - required DataRepository - userContentPreferencesRepository, required FeedDecoratorService feedDecoratorService, required AppBloc appBloc, required InlineAdCacheService inlineAdCacheService, }) : _headlinesRepository = headlinesRepository, - _userContentPreferencesRepository = userContentPreferencesRepository, _feedDecoratorService = feedDecoratorService, _appBloc = appBloc, _inlineAdCacheService = inlineAdCacheService, @@ -63,8 +60,6 @@ class HeadlinesFeedBloc extends Bloc { } final DataRepository _headlinesRepository; - final DataRepository - _userContentPreferencesRepository; final FeedDecoratorService _feedDecoratorService; final AppBloc _appBloc; final InlineAdCacheService _inlineAdCacheService; @@ -131,7 +126,7 @@ class HeadlinesFeedBloc extends Bloc { feedItems: headlineResponse.items, user: currentUser, adConfig: remoteConfig.adConfig, - imageStyle: _appBloc.state.settings.feedPreferences.headlineImageStyle, + imageStyle: _appBloc.state.settings!.feedPreferences.headlineImageStyle, adThemeStyle: event.adThemeStyle, // Calculate the count of actual content items (headlines) already in the // feed. This is crucial for the FeedDecoratorService to correctly apply @@ -174,13 +169,8 @@ class HeadlinesFeedBloc extends Bloc { sort: [const SortOption('updatedAt', SortOrder.desc)], ); - // Fetch user content preferences to get followed items for filtering suggestions. - final userPreferences = currentUser?.id != null - ? await _userContentPreferencesRepository.read( - id: currentUser!.id, - userId: currentUser.id, - ) - : null; + // Use user content preferences from AppBloc for followed items. + final userPreferences = _appBloc.state.userContentPreferences; // For a major load, use the full decoration pipeline, which includes // injecting a high-priority decorator and stateless ad placeholders. @@ -193,7 +183,7 @@ class HeadlinesFeedBloc extends Bloc { userPreferences?.followedTopics.map((t) => t.id).toList() ?? [], followedSourceIds: userPreferences?.followedSources.map((s) => s.id).toList() ?? [], - imageStyle: _appBloc.state.settings.feedPreferences.headlineImageStyle, + imageStyle: _appBloc.state.settings!.feedPreferences.headlineImageStyle, adThemeStyle: event.adThemeStyle, ); @@ -264,13 +254,8 @@ class HeadlinesFeedBloc extends Bloc { sort: [const SortOption('updatedAt', SortOrder.desc)], ); - // Fetch user content preferences to get followed items for filtering suggestions. - final userPreferences = currentUser?.id != null - ? await _userContentPreferencesRepository.read( - id: currentUser!.id, - userId: currentUser.id, - ) - : null; + // Use user content preferences from AppBloc for followed items. + final userPreferences = _appBloc.state.userContentPreferences; // Use the full decoration pipeline, which includes injecting a // high-priority decorator and stateless ad placeholders. @@ -283,7 +268,7 @@ class HeadlinesFeedBloc extends Bloc { userPreferences?.followedTopics.map((t) => t.id).toList() ?? [], followedSourceIds: userPreferences?.followedSources.map((s) => s.id).toList() ?? [], - imageStyle: _appBloc.state.settings.feedPreferences.headlineImageStyle, + imageStyle: _appBloc.state.settings!.feedPreferences.headlineImageStyle, adThemeStyle: event.adThemeStyle, ); @@ -350,16 +335,8 @@ class HeadlinesFeedBloc extends Bloc { sort: [const SortOption('updatedAt', SortOrder.desc)], ); - // Fetch user content preferences to get followed items for filtering suggestions. - final userPreferences = currentUser?.id != null - ? await _userContentPreferencesRepository.read( - id: currentUser!.id, - // In the demo environment, `DataInMemory` requires `userId` for correct - // scoping of user-specific data. In development/production, the backend - // handles this scoping, making `userId` optional at the client level. - userId: currentUser.id, - ) - : null; + // Use user content preferences from AppBloc for followed items. + final userPreferences = _appBloc.state.userContentPreferences; // Use the full decoration pipeline, which includes injecting a // high-priority decorator and stateless ad placeholders. @@ -372,7 +349,7 @@ class HeadlinesFeedBloc extends Bloc { userPreferences?.followedTopics.map((t) => t.id).toList() ?? [], followedSourceIds: userPreferences?.followedSources.map((s) => s.id).toList() ?? [], - imageStyle: _appBloc.state.settings.feedPreferences.headlineImageStyle, + imageStyle: _appBloc.state.settings!.feedPreferences.headlineImageStyle, adThemeStyle: event.adThemeStyle, ); From 471b8875e14c73373a041b81cca1aa0fb41bba86 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 08:40:33 +0100 Subject: [PATCH 58/94] refactor(headlines-feed): remove unused user content preferences functionality - Remove userContentPreferencesRepository from constructor - Delete _onSourcesFilterApplyFollowedRequested method - Remove SourcesFilterApplyFollowedRequested event handling - Eliminate followedSources related state fields and logic --- .../bloc/sources_filter_bloc.dart | 65 ------------------- 1 file changed, 65 deletions(-) diff --git a/lib/headlines-feed/bloc/sources_filter_bloc.dart b/lib/headlines-feed/bloc/sources_filter_bloc.dart index d0b6efe7..e4313de7 100644 --- a/lib/headlines-feed/bloc/sources_filter_bloc.dart +++ b/lib/headlines-feed/bloc/sources_filter_bloc.dart @@ -13,12 +13,9 @@ class SourcesFilterBloc extends Bloc { SourcesFilterBloc({ required DataRepository sourcesRepository, required DataRepository countriesRepository, - required DataRepository - userContentPreferencesRepository, required AppBloc appBloc, }) : _sourcesRepository = sourcesRepository, _countriesRepository = countriesRepository, - _userContentPreferencesRepository = userContentPreferencesRepository, _appBloc = appBloc, super(const SourcesFilterState()) { on(_onLoadSourceFilterData); @@ -26,15 +23,10 @@ class SourcesFilterBloc extends Bloc { on(_onAllSourceTypesCapsuleToggled); on(_onSourceTypeCapsuleToggled); on(_onSourceCheckboxToggled); - on( - _onSourcesFilterApplyFollowedRequested, - ); } final DataRepository _sourcesRepository; final DataRepository _countriesRepository; - final DataRepository - _userContentPreferencesRepository; final AppBloc _appBloc; Future _onLoadSourceFilterData( @@ -176,63 +168,6 @@ class SourcesFilterBloc extends Bloc { emit(state.copyWith(finallySelectedSourceIds: currentSelected)); } - /// Handles the request to apply the user's followed sources as filters. - Future _onSourcesFilterApplyFollowedRequested( - SourcesFilterApplyFollowedRequested event, - Emitter emit, - ) async { - emit( - state.copyWith( - followedSourcesStatus: SourceFilterDataLoadingStatus.loading, - ), - ); - - final currentUser = _appBloc.state.user!; - - try { - final preferences = await _userContentPreferencesRepository.read( - id: currentUser.id, - userId: currentUser.id, - ); - - if (preferences.followedSources.isEmpty) { - emit( - state.copyWith( - followedSourcesStatus: SourceFilterDataLoadingStatus.success, - followedSources: const [], - clearErrorMessage: true, - ), - ); - return; - } - - emit( - state.copyWith( - followedSourcesStatus: SourceFilterDataLoadingStatus.success, - followedSources: preferences.followedSources, - finallySelectedSourceIds: preferences.followedSources - .map((s) => s.id) - .toSet(), - clearFollowedSourcesError: true, - ), - ); - } on HttpException catch (e) { - emit( - state.copyWith( - followedSourcesStatus: SourceFilterDataLoadingStatus.failure, - error: e, - ), - ); - } catch (e) { - emit( - state.copyWith( - followedSourcesStatus: SourceFilterDataLoadingStatus.failure, - error: UnknownException(e.toString()), - ), - ); - } - } - // Helper method to filter sources based on selected countries and types List _getFilteredSources({ required List allSources, From f6cb77a06b875f7788a530bd42acdde90ed96d65 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 08:40:43 +0100 Subject: [PATCH 59/94] refactor(headlines-feed): improve SourcesFilterApplyFollowedRequested event - Rename class to SourceFilterFollowedApplied for better clarity - Add followedSources parameter to carry the list of sources - Update documentation to reflect the new event purpose --- lib/headlines-feed/bloc/sources_filter_event.dart | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/headlines-feed/bloc/sources_filter_event.dart b/lib/headlines-feed/bloc/sources_filter_event.dart index 3105fd82..335c16c4 100644 --- a/lib/headlines-feed/bloc/sources_filter_event.dart +++ b/lib/headlines-feed/bloc/sources_filter_event.dart @@ -62,7 +62,16 @@ class SourceCheckboxToggled extends SourcesFilterEvent { List get props => [sourceId, isSelected]; } -/// {@template sources_filter_apply_followed_requested} -/// Event triggered to request applying the user's followed sources as filters. +/// {@template source_filter_followed_applied} +/// Event triggered when the user's followed sources are applied as filters. /// {@endtemplate} -final class SourcesFilterApplyFollowedRequested extends SourcesFilterEvent {} +final class SourceFilterFollowedApplied extends SourcesFilterEvent { + /// {@macro source_filter_followed_applied} + const SourceFilterFollowedApplied({required this.followedSources}); + + /// The list of sources the user is following. + final List followedSources; + + @override + List get props => [followedSources]; +} From 1cde4c5d98d2389750a74a178a47eb773e16557d Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 08:40:53 +0100 Subject: [PATCH 60/94] refactor(headlines-feed): remove followed sources from filter state - Remove followedSources and followedSourcesStatus from SourcesFilterState - Remove unnecessary imports and comments related to followed sources - Update copyWith method to remove followedSources-related parameters - Update props list in SourcesFilterState to remove followedSources-related items --- .../bloc/sources_filter_state.dart | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/lib/headlines-feed/bloc/sources_filter_state.dart b/lib/headlines-feed/bloc/sources_filter_state.dart index a3d52146..e16ba901 100644 --- a/lib/headlines-feed/bloc/sources_filter_state.dart +++ b/lib/headlines-feed/bloc/sources_filter_state.dart @@ -1,7 +1,5 @@ part of 'sources_filter_bloc.dart'; -// Import for Country, Source, SourceType will be in sources_filter_bloc.dart - enum SourceFilterDataLoadingStatus { initial, loading, success, failure } class SourcesFilterState extends Equatable { @@ -15,8 +13,6 @@ class SourcesFilterState extends Equatable { this.finallySelectedSourceIds = const {}, this.dataLoadingStatus = SourceFilterDataLoadingStatus.initial, this.error, - this.followedSourcesStatus = SourceFilterDataLoadingStatus.initial, - this.followedSources = const [], }); final List countriesWithActiveSources; @@ -29,12 +25,6 @@ class SourcesFilterState extends Equatable { final SourceFilterDataLoadingStatus dataLoadingStatus; final HttpException? error; - /// The current status of fetching followed sources. - final SourceFilterDataLoadingStatus followedSourcesStatus; - - /// The list of [Source] objects representing the user's followed sources. - final List followedSources; - SourcesFilterState copyWith({ List? countriesWithActiveSources, Set? selectedCountryIsoCodes, @@ -45,10 +35,7 @@ class SourcesFilterState extends Equatable { Set? finallySelectedSourceIds, SourceFilterDataLoadingStatus? dataLoadingStatus, HttpException? error, - SourceFilterDataLoadingStatus? followedSourcesStatus, - List? followedSources, bool clearErrorMessage = false, - bool clearFollowedSourcesError = false, }) { return SourcesFilterState( countriesWithActiveSources: @@ -63,9 +50,6 @@ class SourcesFilterState extends Equatable { finallySelectedSourceIds ?? this.finallySelectedSourceIds, dataLoadingStatus: dataLoadingStatus ?? this.dataLoadingStatus, error: clearErrorMessage ? null : error ?? this.error, - followedSourcesStatus: - followedSourcesStatus ?? this.followedSourcesStatus, - followedSources: followedSources ?? this.followedSources, ); } @@ -80,7 +64,5 @@ class SourcesFilterState extends Equatable { finallySelectedSourceIds, dataLoadingStatus, error, - followedSourcesStatus, - followedSources, ]; } From 3ae57f5638d4b46f79ddd5174c5baf69604da62d Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 08:41:17 +0100 Subject: [PATCH 61/94] refactor(topics-filter): remove followed topics functionality - Remove TopicsFilterApplyFollowedRequested event handling - Remove related repository and state fields - Simplify class structure and initialization --- .../bloc/topics_filter_bloc.dart | 60 ------------------- 1 file changed, 60 deletions(-) diff --git a/lib/headlines-feed/bloc/topics_filter_bloc.dart b/lib/headlines-feed/bloc/topics_filter_bloc.dart index e61c77d1..460988dd 100644 --- a/lib/headlines-feed/bloc/topics_filter_bloc.dart +++ b/lib/headlines-feed/bloc/topics_filter_bloc.dart @@ -22,11 +22,8 @@ class TopicsFilterBloc extends Bloc { /// Requires a [DataRepository] to interact with the data layer. TopicsFilterBloc({ required DataRepository topicsRepository, - required DataRepository - userContentPreferencesRepository, required AppBloc appBloc, }) : _topicsRepository = topicsRepository, - _userContentPreferencesRepository = userContentPreferencesRepository, _appBloc = appBloc, super(const TopicsFilterState()) { on( @@ -37,15 +34,9 @@ class TopicsFilterBloc extends Bloc { _onTopicsFilterLoadMoreRequested, transformer: droppable(), ); - on( - _onTopicsFilterApplyFollowedRequested, - transformer: restartable(), - ); } final DataRepository _topicsRepository; - final DataRepository - _userContentPreferencesRepository; final AppBloc _appBloc; /// Number of topics to fetch per page. @@ -59,7 +50,6 @@ class TopicsFilterBloc extends Bloc { // Prevent fetching if already loading or successful (unless forced refresh) if (state.status == TopicsFilterStatus.loading || state.status == TopicsFilterStatus.success) { - // Optionally add logic here for forced refresh if needed return; } @@ -118,54 +108,4 @@ class TopicsFilterBloc extends Bloc { emit(state.copyWith(status: TopicsFilterStatus.failure, error: e)); } } - - /// Handles the request to apply the user's followed topics as filters. - Future _onTopicsFilterApplyFollowedRequested( - TopicsFilterApplyFollowedRequested event, - Emitter emit, - ) async { - emit(state.copyWith(followedTopicsStatus: TopicsFilterStatus.loading)); - - final currentUser = _appBloc.state.user!; - - try { - final preferences = await _userContentPreferencesRepository.read( - id: currentUser.id, - userId: currentUser.id, - ); - - if (preferences.followedTopics.isEmpty) { - emit( - state.copyWith( - followedTopicsStatus: TopicsFilterStatus.success, - followedTopics: const [], - clearError: true, - ), - ); - return; - } - - emit( - state.copyWith( - followedTopicsStatus: TopicsFilterStatus.success, - followedTopics: preferences.followedTopics, - clearFollowedTopicsError: true, - ), - ); - } on HttpException catch (e) { - emit( - state.copyWith( - followedTopicsStatus: TopicsFilterStatus.failure, - error: e, - ), - ); - } catch (e) { - emit( - state.copyWith( - followedTopicsStatus: TopicsFilterStatus.failure, - error: UnknownException(e.toString()), - ), - ); - } - } } From 087bf6f84ca41d1cd845c46c5a5a99b8442ddf61 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 08:41:28 +0100 Subject: [PATCH 62/94] refactor(headlines-feed): remove followed topics from TopicsFilterState - Remove followedTopicsStatus and followedTopics properties - Update copyWith method to remove followedTopics-related parameters - Update props list to exclude followedTopics and followedTopicsStatus --- lib/headlines-feed/bloc/topics_filter_state.dart | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/lib/headlines-feed/bloc/topics_filter_state.dart b/lib/headlines-feed/bloc/topics_filter_state.dart index 1fd3508a..a8903cce 100644 --- a/lib/headlines-feed/bloc/topics_filter_state.dart +++ b/lib/headlines-feed/bloc/topics_filter_state.dart @@ -32,8 +32,6 @@ final class TopicsFilterState extends Equatable { this.hasMore = true, this.cursor, this.error, - this.followedTopicsStatus = TopicsFilterStatus.initial, - this.followedTopics = const [], }); /// The current status of fetching topics. @@ -52,12 +50,6 @@ final class TopicsFilterState extends Equatable { /// An optional error object if the status is [TopicsFilterStatus.failure]. final HttpException? error; - /// The current status of fetching followed topics. - final TopicsFilterStatus followedTopicsStatus; - - /// The list of [Topic] objects representing the user's followed topics. - final List followedTopics; - /// Creates a copy of this state with the given fields replaced. TopicsFilterState copyWith({ TopicsFilterStatus? status, @@ -65,11 +57,8 @@ final class TopicsFilterState extends Equatable { bool? hasMore, String? cursor, HttpException? error, - TopicsFilterStatus? followedTopicsStatus, - List? followedTopics, bool clearError = false, bool clearCursor = false, - bool clearFollowedTopicsError = false, }) { return TopicsFilterState( status: status ?? this.status, @@ -79,8 +68,6 @@ final class TopicsFilterState extends Equatable { cursor: clearCursor ? null : (cursor ?? this.cursor), // Clear error if requested, otherwise keep existing or use new one error: clearError ? null : error ?? this.error, - followedTopicsStatus: followedTopicsStatus ?? this.followedTopicsStatus, - followedTopics: followedTopics ?? this.followedTopics, ); } @@ -91,7 +78,5 @@ final class TopicsFilterState extends Equatable { hasMore, cursor, error, - followedTopicsStatus, - followedTopics, ]; } From 03b539f3a79760345e69607687ddc6343c8f4cb5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 08:41:43 +0100 Subject: [PATCH 63/94] refactor(country-filter): update logic and remove BLoC listener - Replace CountriesFilterBloc with AppBloc for followed countries data - Remove BlocListener and move logic to BlocBuilder - Update UI and snackbar behavior for empty followed countries list - Simplify loading and error state handling --- .../view/country_filter_page.dart | 294 +++++++----------- 1 file changed, 115 insertions(+), 179 deletions(-) diff --git a/lib/headlines-feed/view/country_filter_page.dart b/lib/headlines-feed/view/country_filter_page.dart index 7f889e52..319604e7 100644 --- a/lib/headlines-feed/view/country_filter_page.dart +++ b/lib/headlines-feed/view/country_filter_page.dart @@ -1,6 +1,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/countries_filter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:go_router/go_router.dart'; @@ -96,34 +97,35 @@ class _CountryFilterPageState extends State { ), actions: [ // Apply My Followed Countries Button - BlocBuilder( - builder: (context, state) { - // Determine if the "Apply My Followed" icon should be filled - final followedCountriesSet = state.followedCountries.toSet(); - final isFollowedFilterActive = - followedCountriesSet.isNotEmpty && - _pageSelectedCountries.length == - followedCountriesSet.length && - _pageSelectedCountries.containsAll(followedCountriesSet); + BlocBuilder( + builder: (context, appState) { + final followedCountries = + appState.userContentPreferences?.followedCountries ?? []; + final isFollowedFilterActive = followedCountries.isNotEmpty && + _pageSelectedCountries.length == followedCountries.length && + _pageSelectedCountries.containsAll(followedCountries); return IconButton( icon: isFollowedFilterActive ? const Icon(Icons.favorite) : const Icon(Icons.favorite_border), - color: isFollowedFilterActive - ? theme.colorScheme.primary - : null, + color: isFollowedFilterActive ? theme.colorScheme.primary : null, tooltip: l10n.headlinesFeedFilterApplyFollowedLabel, - onPressed: - state.followedCountriesStatus == - CountriesFilterStatus.loading - ? null // Disable while loading - : () { - // Dispatch event to BLoC to fetch and apply followed countries - _countriesFilterBloc.add( - CountriesFilterApplyFollowedRequested(), - ); - }, + onPressed: () { + setState(() { + _pageSelectedCountries = Set.from(followedCountries); + }); + if (followedCountries.isEmpty) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(l10n.noFollowedItemsForFilterSnackbar), + duration: const Duration(seconds: 3), + ), + ); + } + }, ); }, ), @@ -140,171 +142,105 @@ class _CountryFilterPageState extends State { ), ], ), - // Use BlocListener to react to state changes from CountriesFilterBloc - body: BlocListener( - listenWhen: (previous, current) => - previous.followedCountriesStatus != - current.followedCountriesStatus || - previous.followedCountries != current.followedCountries, - listener: (context, state) { - if (state.followedCountriesStatus == CountriesFilterStatus.success) { - // Update local state with followed countries from BLoC - setState(() { - _pageSelectedCountries = Set.from(state.followedCountries); - }); - if (state.followedCountries.isEmpty) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text(l10n.noFollowedItemsForFilterSnackbar), - duration: const Duration(seconds: 3), - ), - ); - } - } else if (state.followedCountriesStatus == - CountriesFilterStatus.failure) { - // Show error message if fetching followed countries failed - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text(state.error?.message ?? l10n.unknownError), - duration: const Duration(seconds: 3), - ), - ); + body: BlocBuilder( + builder: (context, state) { + // Determine overall loading status for the main list + final isLoadingMainList = + state.status == CountriesFilterStatus.loading; + + // Handle initial loading state + if (isLoadingMainList) { + return LoadingStateWidget( + icon: Icons.public_outlined, + headline: l10n.countryFilterLoadingHeadline, + subheadline: l10n.countryFilterLoadingSubheadline, + ); } - }, - child: BlocBuilder( - builder: (context, state) { - // Determine overall loading status for the main list - final isLoadingMainList = - state.status == CountriesFilterStatus.loading; - - // Determine if followed countries are currently loading - final isLoadingFollowedCountries = - state.followedCountriesStatus == CountriesFilterStatus.loading; - // Handle initial loading state - if (isLoadingMainList) { - return LoadingStateWidget( - icon: Icons.public_outlined, - headline: l10n.countryFilterLoadingHeadline, - subheadline: l10n.countryFilterLoadingSubheadline, - ); - } - - // Handle failure state (show error and retry button) - if (state.status == CountriesFilterStatus.failure && - state.countries.isEmpty) { - return FailureStateWidget( - exception: - state.error ?? const UnknownException('Unknown error'), - onRetry: () => _countriesFilterBloc.add( - CountriesFilterRequested(usage: widget.filter), - ), - ); - } - - // Handle empty state (after successful load but no countries found) - if (state.status == CountriesFilterStatus.success && - state.countries.isEmpty) { - return InitialStateWidget( - icon: Icons.flag_circle_outlined, - headline: l10n.countryFilterEmptyHeadline, - subheadline: l10n.countryFilterEmptySubheadline, - ); - } + // Handle failure state (show error and retry button) + if (state.status == CountriesFilterStatus.failure && + state.countries.isEmpty) { + return FailureStateWidget( + exception: + state.error ?? const UnknownException('Unknown error'), + onRetry: () => _countriesFilterBloc.add( + CountriesFilterRequested(usage: widget.filter), + ), + ); + } - // Handle loaded state (success) - return Stack( - children: [ - ListView.builder( - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.paddingSmall, - ).copyWith(bottom: AppSpacing.xxl), - itemCount: state.countries.length, - itemBuilder: (context, index) { - final country = state.countries[index]; - final isSelected = _pageSelectedCountries.contains(country); + // Handle empty state (after successful load but no countries found) + if (state.status == CountriesFilterStatus.success && + state.countries.isEmpty) { + return InitialStateWidget( + icon: Icons.flag_circle_outlined, + headline: l10n.countryFilterEmptyHeadline, + subheadline: l10n.countryFilterEmptySubheadline, + ); + } - return CheckboxListTile( - title: Text(country.name, style: textTheme.titleMedium), - secondary: SizedBox( - width: AppSpacing.xl + AppSpacing.xs, - height: AppSpacing.lg + AppSpacing.sm, - child: ClipRRect( - // Clip the image for rounded corners if desired - borderRadius: BorderRadius.circular( - AppSpacing.xs / 2, - ), - child: Image.network( - country.flagUrl, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Icon( - Icons.flag_outlined, - color: theme.colorScheme.onSurfaceVariant, - size: AppSpacing.lg, - ), - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Center( - child: CircularProgressIndicator( - strokeWidth: 2, - value: - loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ); - }, - ), - ), + // Handle loaded state (success) + return ListView.builder( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.paddingSmall, + ).copyWith(bottom: AppSpacing.xxl), + itemCount: state.countries.length, + itemBuilder: (context, index) { + final country = state.countries[index]; + final isSelected = _pageSelectedCountries.contains(country); + + return CheckboxListTile( + title: Text(country.name, style: textTheme.titleMedium), + secondary: SizedBox( + width: AppSpacing.xl + AppSpacing.xs, + height: AppSpacing.lg + AppSpacing.sm, + child: ClipRRect( + // Clip the image for rounded corners if desired + borderRadius: BorderRadius.circular( + AppSpacing.xs / 2, + ), + child: Image.network( + country.flagUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Icon( + Icons.flag_outlined, + color: theme.colorScheme.onSurfaceVariant, + size: AppSpacing.lg, ), - value: isSelected, - onChanged: (bool? value) { - setState(() { - if (value == true) { - _pageSelectedCountries.add(country); - } else { - _pageSelectedCountries.remove(country); - } - }); + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + strokeWidth: 2, + value: + loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ); }, - controlAffinity: ListTileControlAffinity.leading, - contentPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingMedium, - ), - ); - }, - ), - // Show loading overlay if followed countries are being fetched - if (isLoadingFollowedCountries) - Positioned.fill( - child: ColoredBox( - color: Colors.black54, // Semi-transparent overlay - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: AppSpacing.md), - Text( - l10n.headlinesFeedLoadingHeadline, - style: theme.textTheme.titleMedium?.copyWith( - color: Colors.white, - ), - ), - ], - ), - ), ), ), - ], - ); - }, - ), + ), + value: isSelected, + onChanged: (bool? value) { + setState(() { + if (value == true) { + _pageSelectedCountries.add(country); + } else { + _pageSelectedCountries.remove(country); + } + }); + }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + ), + ); + }, + ); + }, ), ); } From 78ae11681ae649edf268430ac4e4522bff8ccc0a Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 08:41:57 +0100 Subject: [PATCH 64/94] refactor(headlines-feed): improve source filter page logic and UI - Remove unnecessary listener and state checks - Optimize followed sources logic and UI feedback - Simplify loading and error handling - Adjust UI layout and remove overlay for followed sources loading --- .../view/source_filter_page.dart | 227 +++++++----------- 1 file changed, 86 insertions(+), 141 deletions(-) diff --git a/lib/headlines-feed/view/source_filter_page.dart b/lib/headlines-feed/view/source_filter_page.dart index 460c3661..5d5a2ea1 100644 --- a/lib/headlines-feed/view/source_filter_page.dart +++ b/lib/headlines-feed/view/source_filter_page.dart @@ -30,18 +30,15 @@ class SourceFilterPage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => - SourcesFilterBloc( - sourcesRepository: context.read>(), - countriesRepository: context.read>(), - userContentPreferencesRepository: context - .read>(), - appBloc: context.read(), - )..add( - LoadSourceFilterData( - initialSelectedSources: initialSelectedSources, - ), + create: (context) => SourcesFilterBloc( + sourcesRepository: context.read>(), + countriesRepository: context.read>(), + appBloc: context.read(), + )..add( + LoadSourceFilterData( + initialSelectedSources: initialSelectedSources, ), + ), child: const _SourceFilterView(), ); } @@ -65,16 +62,18 @@ class _SourceFilterView extends StatelessWidget { ), actions: [ // Apply My Followed Sources Button - BlocBuilder( - builder: (context, state) { - // Determine if the "Apply My Followed" icon should be filled - final isFollowedFilterActive = - state.followedSources.isNotEmpty && - state.finallySelectedSourceIds.length == - state.followedSources.length && - state.followedSources.every( - (source) => - state.finallySelectedSourceIds.contains(source.id), + BlocBuilder( + builder: (context, appState) { + final followedSources = + appState.userContentPreferences?.followedSources ?? []; + final sourcesFilterBloc = context.read(); + + final isFollowedFilterActive = followedSources.isNotEmpty && + sourcesFilterBloc.state.finallySelectedSourceIds.length == + followedSources.length && + followedSources.every( + (source) => sourcesFilterBloc.state.finallySelectedSourceIds + .contains(source.id), ); return IconButton( @@ -85,16 +84,23 @@ class _SourceFilterView extends StatelessWidget { ? theme.colorScheme.primary : null, tooltip: l10n.headlinesFeedFilterApplyFollowedLabel, - onPressed: - state.followedSourcesStatus == - SourceFilterDataLoadingStatus.loading - ? null // Disable while loading - : () { - // Dispatch event to BLoC to fetch and apply followed sources - context.read().add( - SourcesFilterApplyFollowedRequested(), - ); - }, + onPressed: () { + sourcesFilterBloc.add( + SourceFilterFollowedApplied( + followedSources: followedSources, + ), + ); + if (followedSources.isEmpty) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(l10n.noFollowedItemsForFilterSnackbar), + duration: const Duration(seconds: 3), + ), + ); + } + }, ); }, ), @@ -112,121 +118,60 @@ class _SourceFilterView extends StatelessWidget { ), ], ), - body: BlocListener( - listenWhen: (previous, current) => - previous.followedSourcesStatus != current.followedSourcesStatus || - previous.followedSources != current.followedSources, - listener: (context, state) { - if (state.followedSourcesStatus == - SourceFilterDataLoadingStatus.success) { - if (state.followedSources.isEmpty) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text(l10n.noFollowedItemsForFilterSnackbar), - duration: const Duration(seconds: 3), + body: BlocBuilder( + builder: (context, state) { + final isLoadingMainList = + state.dataLoadingStatus == + SourceFilterDataLoadingStatus.loading && + state.allAvailableSources.isEmpty; + + if (isLoadingMainList) { + return LoadingStateWidget( + icon: Icons.source_outlined, + headline: l10n.sourceFilterLoadingHeadline, + subheadline: l10n.sourceFilterLoadingSubheadline, + ); + } + if (state.dataLoadingStatus == + SourceFilterDataLoadingStatus.failure && + state.allAvailableSources.isEmpty) { + return FailureStateWidget( + exception: + state.error ?? + const UnknownException( + 'Failed to load source filter data.', ), - ); - } - } else if (state.followedSourcesStatus == - SourceFilterDataLoadingStatus.failure) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text(state.error?.message ?? l10n.unknownError), - duration: const Duration(seconds: 3), - ), - ); + onRetry: () { + context.read().add(const LoadSourceFilterData()); + }, + ); } - }, - child: BlocBuilder( - builder: (context, state) { - final isLoadingMainList = - state.dataLoadingStatus == - SourceFilterDataLoadingStatus.loading && - state.allAvailableSources.isEmpty; - final isLoadingFollowedSources = - state.followedSourcesStatus == - SourceFilterDataLoadingStatus.loading; - if (isLoadingMainList) { - return LoadingStateWidget( - icon: Icons.source_outlined, - headline: l10n.sourceFilterLoadingHeadline, - subheadline: l10n.sourceFilterLoadingSubheadline, - ); - } - if (state.dataLoadingStatus == - SourceFilterDataLoadingStatus.failure && - state.allAvailableSources.isEmpty) { - return FailureStateWidget( - exception: - state.error ?? - const UnknownException( - 'Failed to load source filter data.', - ), - onRetry: () { - context.read().add( - const LoadSourceFilterData(), - ); - }, - ); - } - - return Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildCountryCapsules(context, state, l10n, textTheme), - const SizedBox(height: AppSpacing.md), - _buildSourceTypeCapsules(context, state, l10n, textTheme), - const SizedBox(height: AppSpacing.md), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingMedium, - ), - child: Text( - l10n.headlinesFeedFilterSourceLabel, - style: textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: AppSpacing.sm), - Expanded( - child: _buildSourcesList(context, state, l10n, textTheme), - ), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCountryCapsules(context, state, l10n, textTheme), + const SizedBox(height: AppSpacing.md), + _buildSourceTypeCapsules(context, state, l10n, textTheme), + const SizedBox(height: AppSpacing.md), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, ), - // Show loading overlay if followed sources are being fetched - if (isLoadingFollowedSources) - Positioned.fill( - child: ColoredBox( - color: Colors.black54, // Semi-transparent overlay - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: AppSpacing.md), - Text( - l10n.headlinesFeedLoadingHeadline, - style: theme.textTheme.titleMedium?.copyWith( - color: Colors.white, - ), - ), - ], - ), - ), - ), + child: Text( + l10n.headlinesFeedFilterSourceLabel, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, ), - ], - ); - }, - ), + ), + ), + const SizedBox(height: AppSpacing.sm), + Expanded( + child: _buildSourcesList(context, state, l10n, textTheme), + ), + ], + ); + }, ), ); } From 5e3a5562e41edc53582f7b8689597085094b985e Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 10:14:46 +0100 Subject: [PATCH 65/94] refactor(headlines-feed): remove unused AppBloc dependency - Remove import statement for AppBloc - Remove AppBloc parameter from CountriesFilterBloc constructor - Remove unused _appBloc field --- lib/headlines-feed/bloc/countries_filter_bloc.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/headlines-feed/bloc/countries_filter_bloc.dart b/lib/headlines-feed/bloc/countries_filter_bloc.dart index fc1a0181..ae49f8e9 100644 --- a/lib/headlines-feed/bloc/countries_filter_bloc.dart +++ b/lib/headlines-feed/bloc/countries_filter_bloc.dart @@ -5,7 +5,6 @@ import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/view/country_filter_page.dart'; part 'countries_filter_event.dart'; @@ -24,9 +23,7 @@ class CountriesFilterBloc /// Requires a [DataRepository] to interact with the data layer. CountriesFilterBloc({ required DataRepository countriesRepository, - required AppBloc appBloc, // Inject AppBloc }) : _countriesRepository = countriesRepository, - _appBloc = appBloc, super(const CountriesFilterState()) { on( _onCountriesFilterRequested, @@ -35,7 +32,6 @@ class CountriesFilterBloc } final DataRepository _countriesRepository; - final AppBloc _appBloc; /// Handles the request to fetch countries based on a specific usage. /// From a0916946c7e040fc0bd26107aab4e05ff52e537a Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 10:14:57 +0100 Subject: [PATCH 66/94] refactor(headlines-feed): remove unused event class - Remove CountriesFilterApplyFollowedRequested class from countries_filter_event.dart - This class was not being used in the application and was commented out --- lib/headlines-feed/bloc/countries_filter_event.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/headlines-feed/bloc/countries_filter_event.dart b/lib/headlines-feed/bloc/countries_filter_event.dart index 46d329dc..94016061 100644 --- a/lib/headlines-feed/bloc/countries_filter_event.dart +++ b/lib/headlines-feed/bloc/countries_filter_event.dart @@ -27,9 +27,3 @@ final class CountriesFilterRequested extends CountriesFilterEvent { @override List get props => [usage]; } - -/// {@template countries_filter_apply_followed_requested} -/// Event triggered to request applying the user's followed countries as filters. -/// {@endtemplate} -final class CountriesFilterApplyFollowedRequested - extends CountriesFilterEvent {} From 11ab33ac794e9ebfd02e0da01884567aba7e4164 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 10:16:18 +0100 Subject: [PATCH 67/94] refactor(headlines-feed): remove unused dependencies in SourcesFilterBloc - Removed unused imports: app/bloc/app_bloc.dart - Removed unused parameter: AppBloc appBloc from SourcesFilterBloc constructor - Deleted unused property: _appBloc from SourcesFilterBloc class --- lib/headlines-feed/bloc/sources_filter_bloc.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/headlines-feed/bloc/sources_filter_bloc.dart b/lib/headlines-feed/bloc/sources_filter_bloc.dart index e4313de7..d28c7e77 100644 --- a/lib/headlines-feed/bloc/sources_filter_bloc.dart +++ b/lib/headlines-feed/bloc/sources_filter_bloc.dart @@ -4,8 +4,6 @@ import 'package:bloc/bloc.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; - part 'sources_filter_event.dart'; part 'sources_filter_state.dart'; @@ -13,10 +11,8 @@ class SourcesFilterBloc extends Bloc { SourcesFilterBloc({ required DataRepository sourcesRepository, required DataRepository countriesRepository, - required AppBloc appBloc, }) : _sourcesRepository = sourcesRepository, _countriesRepository = countriesRepository, - _appBloc = appBloc, super(const SourcesFilterState()) { on(_onLoadSourceFilterData); on(_onCountryCapsuleToggled); @@ -27,7 +23,6 @@ class SourcesFilterBloc extends Bloc { final DataRepository _sourcesRepository; final DataRepository _countriesRepository; - final AppBloc _appBloc; Future _onLoadSourceFilterData( LoadSourceFilterData event, From d40975b3a7515120749573717bcb9b990a92e91b Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 10:16:38 +0100 Subject: [PATCH 68/94] refactor(headlines-feed): remove unused SourceFilterFollowedApplied event - Deleted the SourceFilterFollowedApplied class from sources_filter_event.dart --- lib/headlines-feed/bloc/sources_filter_event.dart | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/lib/headlines-feed/bloc/sources_filter_event.dart b/lib/headlines-feed/bloc/sources_filter_event.dart index 335c16c4..e5165324 100644 --- a/lib/headlines-feed/bloc/sources_filter_event.dart +++ b/lib/headlines-feed/bloc/sources_filter_event.dart @@ -61,17 +61,3 @@ class SourceCheckboxToggled extends SourcesFilterEvent { @override List get props => [sourceId, isSelected]; } - -/// {@template source_filter_followed_applied} -/// Event triggered when the user's followed sources are applied as filters. -/// {@endtemplate} -final class SourceFilterFollowedApplied extends SourcesFilterEvent { - /// {@macro source_filter_followed_applied} - const SourceFilterFollowedApplied({required this.followedSources}); - - /// The list of sources the user is following. - final List followedSources; - - @override - List get props => [followedSources]; -} From c475cc1813961575155c1aca44f471c0db89d794 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 10:16:51 +0100 Subject: [PATCH 69/94] refactor(headlines-feed): remove unused appBloc dependency - Removed AppBloc import from topics_filter_bloc.dart - Removed appBloc parameter from TopicsFilterBloc constructor - Deleted _appBloc field from TopicsFilterBloc class --- lib/headlines-feed/bloc/topics_filter_bloc.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/headlines-feed/bloc/topics_filter_bloc.dart b/lib/headlines-feed/bloc/topics_filter_bloc.dart index 460988dd..e733d98a 100644 --- a/lib/headlines-feed/bloc/topics_filter_bloc.dart +++ b/lib/headlines-feed/bloc/topics_filter_bloc.dart @@ -5,8 +5,6 @@ import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; - part 'topics_filter_event.dart'; part 'topics_filter_state.dart'; @@ -22,9 +20,7 @@ class TopicsFilterBloc extends Bloc { /// Requires a [DataRepository] to interact with the data layer. TopicsFilterBloc({ required DataRepository topicsRepository, - required AppBloc appBloc, }) : _topicsRepository = topicsRepository, - _appBloc = appBloc, super(const TopicsFilterState()) { on( _onTopicsFilterRequested, @@ -37,7 +33,6 @@ class TopicsFilterBloc extends Bloc { } final DataRepository _topicsRepository; - final AppBloc _appBloc; /// Number of topics to fetch per page. static const _topicsLimit = 20; From 8a407415a8226dc54d0afe2097058478243f0c12 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 10:16:58 +0100 Subject: [PATCH 70/94] refactor(headlines-feed): remove unused TopicsFilterApplyFollowedRequested event Removed the unused TopicsFilterApplyFollowedRequested event class from the topics filter bloc. This event was intended to apply the user's followed topics as filters but is no longer needed in the current implementation. - Deleted TopicsFilterApplyFollowedRequested class - Updated file to remove unnecessary code and improve maintainability --- lib/headlines-feed/bloc/topics_filter_event.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/headlines-feed/bloc/topics_filter_event.dart b/lib/headlines-feed/bloc/topics_filter_event.dart index bc2ce98c..14d08ed7 100644 --- a/lib/headlines-feed/bloc/topics_filter_event.dart +++ b/lib/headlines-feed/bloc/topics_filter_event.dart @@ -20,8 +20,3 @@ final class TopicsFilterRequested extends TopicsFilterEvent {} /// Event triggered to request the next page of topics for pagination. /// {@endtemplate} final class TopicsFilterLoadMoreRequested extends TopicsFilterEvent {} - -/// {@template topics_filter_apply_followed_requested} -/// Event triggered to request applying the user's followed topics as filters. -/// {@endtemplate} -final class TopicsFilterApplyFollowedRequested extends TopicsFilterEvent {} From 7b947858e197b91fb0dcfe525ba5444dc54e95f3 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 10:17:10 +0100 Subject: [PATCH 71/94] refactor(headlines-feed): remove CountriesFilterBloc from country filter page - Remove _countriesFilterBloc property and its initialization - Remove CountriesFilterRequested event triggering - Update context reads to use CountriesFilterBloc directly - Adjust UI logic to remove dependency on _countriesFilterBloc --- .../view/country_filter_page.dart | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/lib/headlines-feed/view/country_filter_page.dart b/lib/headlines-feed/view/country_filter_page.dart index 319604e7..4301e0f4 100644 --- a/lib/headlines-feed/view/country_filter_page.dart +++ b/lib/headlines-feed/view/country_filter_page.dart @@ -49,12 +49,10 @@ class _CountryFilterPageState extends State { /// `HeadlinesFilterPage`. This ensures the checkboxes reflect the state /// from the main filter page when this page loads. late Set _pageSelectedCountries; - late final CountriesFilterBloc _countriesFilterBloc; @override void initState() { super.initState(); - _countriesFilterBloc = context.read(); // Initialization needs to happen after the first frame to safely access // GoRouterState.of(context). @@ -70,11 +68,6 @@ class _CountryFilterPageState extends State { // This ensures the checkboxes on this page are initially checked // correctly based on the selections made previously. _pageSelectedCountries = Set.from(initialSelection ?? []); - - // 3. Trigger the page-specific BLoC (CountriesFilterBloc) to start - // fetching the list of *all available* countries that the user can - // potentially select from, using the specified usage filter. - _countriesFilterBloc.add(CountriesFilterRequested(usage: widget.filter)); }); } @@ -101,7 +94,8 @@ class _CountryFilterPageState extends State { builder: (context, appState) { final followedCountries = appState.userContentPreferences?.followedCountries ?? []; - final isFollowedFilterActive = followedCountries.isNotEmpty && + final isFollowedFilterActive = + followedCountries.isNotEmpty && _pageSelectedCountries.length == followedCountries.length && _pageSelectedCountries.containsAll(followedCountries); @@ -109,7 +103,9 @@ class _CountryFilterPageState extends State { icon: isFollowedFilterActive ? const Icon(Icons.favorite) : const Icon(Icons.favorite_border), - color: isFollowedFilterActive ? theme.colorScheme.primary : null, + color: isFollowedFilterActive + ? theme.colorScheme.primary + : null, tooltip: l10n.headlinesFeedFilterApplyFollowedLabel, onPressed: () { setState(() { @@ -161,9 +157,8 @@ class _CountryFilterPageState extends State { if (state.status == CountriesFilterStatus.failure && state.countries.isEmpty) { return FailureStateWidget( - exception: - state.error ?? const UnknownException('Unknown error'), - onRetry: () => _countriesFilterBloc.add( + exception: state.error ?? const UnknownException('Unknown error'), + onRetry: () => context.read().add( CountriesFilterRequested(usage: widget.filter), ), ); @@ -196,9 +191,7 @@ class _CountryFilterPageState extends State { height: AppSpacing.lg + AppSpacing.sm, child: ClipRRect( // Clip the image for rounded corners if desired - borderRadius: BorderRadius.circular( - AppSpacing.xs / 2, - ), + borderRadius: BorderRadius.circular(AppSpacing.xs / 2), child: Image.network( country.flagUrl, fit: BoxFit.cover, @@ -212,8 +205,7 @@ class _CountryFilterPageState extends State { return Center( child: CircularProgressIndicator( strokeWidth: 2, - value: - loadingProgress.expectedTotalBytes != null + value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null, From c2a8406afb6719c19fe8fb5edea77e2f706d6177 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 10:17:44 +0100 Subject: [PATCH 72/94] refactor(headlines-feed): update topic filter page logic - Replace TopicsFilterBloc with AppBloc for followed topics data - Simplify logic for applying followed topics filter - Remove unnecessary loading state and error handling for followed topics - Optimize UI updates and snackbar messages --- .../view/topic_filter_page.dart | 225 +++++++----------- 1 file changed, 83 insertions(+), 142 deletions(-) diff --git a/lib/headlines-feed/view/topic_filter_page.dart b/lib/headlines-feed/view/topic_filter_page.dart index d2425020..b6fbeab3 100644 --- a/lib/headlines-feed/view/topic_filter_page.dart +++ b/lib/headlines-feed/view/topic_filter_page.dart @@ -1,6 +1,8 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/topics_filter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:go_router/go_router.dart'; @@ -68,16 +70,13 @@ class _TopicFilterPageState extends State { ), actions: [ // Apply My Followed Topics Button - BlocBuilder( - builder: (context, state) { - // Determine if the "Apply My Followed" icon should be filled - // This logic checks if all currently selected topics are - // also present in the fetched followed topics list. - final followedTopicsSet = state.followedTopics.toSet(); - final isFollowedFilterActive = - followedTopicsSet.isNotEmpty && - _pageSelectedTopics.length == followedTopicsSet.length && - _pageSelectedTopics.containsAll(followedTopicsSet); + BlocBuilder( + builder: (context, appState) { + final followedTopics = + appState.userContentPreferences?.followedTopics ?? []; + final isFollowedFilterActive = followedTopics.isNotEmpty && + _pageSelectedTopics.length == followedTopics.length && + _pageSelectedTopics.containsAll(followedTopics); return IconButton( icon: isFollowedFilterActive @@ -87,15 +86,21 @@ class _TopicFilterPageState extends State { ? theme.colorScheme.primary : null, tooltip: l10n.headlinesFeedFilterApplyFollowedLabel, - onPressed: - state.followedTopicsStatus == TopicsFilterStatus.loading - ? null // Disable while loading - : () { - // Dispatch event to BLoC to fetch and apply followed topics - _topicsFilterBloc.add( - TopicsFilterApplyFollowedRequested(), - ); - }, + onPressed: () { + setState(() { + _pageSelectedTopics = Set.from(followedTopics); + }); + if (followedTopics.isEmpty) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(l10n.noFollowedItemsForFilterSnackbar), + duration: const Duration(seconds: 3), + ), + ); + } + }, ); }, ), @@ -109,134 +114,70 @@ class _TopicFilterPageState extends State { ), ], ), - body: BlocListener( - // Listen for changes in followedTopicsStatus or followedTopics - listenWhen: (previous, current) => - previous.followedTopicsStatus != current.followedTopicsStatus || - previous.followedTopics != current.followedTopics, - listener: (context, state) { - if (state.followedTopicsStatus == TopicsFilterStatus.success) { - // Update local state with followed topics from BLoC - setState(() { - _pageSelectedTopics = Set.from(state.followedTopics); - }); - if (state.followedTopics.isEmpty) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text(l10n.noFollowedItemsForFilterSnackbar), - duration: const Duration(seconds: 3), - ), - ); - } - } else if (state.followedTopicsStatus == TopicsFilterStatus.failure) { - // Show error message if fetching followed topics failed - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text(state.error?.message ?? l10n.unknownError), - duration: const Duration(seconds: 3), - ), - ); + body: BlocBuilder( + builder: (context, state) { + // Determine overall loading status for the main list + final isLoadingMainList = + state.status == TopicsFilterStatus.initial || + state.status == TopicsFilterStatus.loading; + + if (isLoadingMainList) { + return LoadingStateWidget( + icon: Icons.category_outlined, + headline: l10n.topicFilterLoadingHeadline, + subheadline: l10n.pleaseWait, + ); } - }, - child: BlocBuilder( - builder: (context, state) { - // Determine overall loading status for the main list - final isLoadingMainList = - state.status == TopicsFilterStatus.initial || - state.status == TopicsFilterStatus.loading; - - // Determine if followed topics are currently loading - final isLoadingFollowedTopics = - state.followedTopicsStatus == TopicsFilterStatus.loading; - if (isLoadingMainList) { - return LoadingStateWidget( - icon: Icons.category_outlined, - headline: l10n.topicFilterLoadingHeadline, - subheadline: l10n.pleaseWait, - ); - } - - if (state.status == TopicsFilterStatus.failure && - state.topics.isEmpty) { - return Center( - child: FailureStateWidget( - exception: - state.error ?? - const UnknownException( - 'An unknown error occurred while fetching topics.', - ), - onRetry: () => _topicsFilterBloc.add(TopicsFilterRequested()), - ), - ); - } + if (state.status == TopicsFilterStatus.failure && + state.topics.isEmpty) { + return Center( + child: FailureStateWidget( + exception: + state.error ?? + const UnknownException( + 'An unknown error occurred while fetching topics.', + ), + onRetry: () => _topicsFilterBloc.add(TopicsFilterRequested()), + ), + ); + } - if (state.topics.isEmpty) { - return InitialStateWidget( - icon: Icons.category_outlined, - headline: l10n.topicFilterEmptyHeadline, - subheadline: l10n.topicFilterEmptySubheadline, - ); - } + if (state.topics.isEmpty) { + return InitialStateWidget( + icon: Icons.category_outlined, + headline: l10n.topicFilterEmptyHeadline, + subheadline: l10n.topicFilterEmptySubheadline, + ); + } - return Stack( - children: [ - ListView.builder( - controller: _scrollController, - itemCount: state.hasMore - ? state.topics.length + 1 - : state.topics.length, - itemBuilder: (context, index) { - if (index >= state.topics.length) { - return const Center(child: CircularProgressIndicator()); + return ListView.builder( + controller: _scrollController, + itemCount: state.hasMore + ? state.topics.length + 1 + : state.topics.length, + itemBuilder: (context, index) { + if (index >= state.topics.length) { + return const Center(child: CircularProgressIndicator()); + } + final topic = state.topics[index]; + final isSelected = _pageSelectedTopics.contains(topic); + return CheckboxListTile( + title: Text(topic.name), + value: isSelected, + onChanged: (bool? value) { + setState(() { + if (value == true) { + _pageSelectedTopics.add(topic); + } else { + _pageSelectedTopics.remove(topic); } - final topic = state.topics[index]; - final isSelected = _pageSelectedTopics.contains(topic); - return CheckboxListTile( - title: Text(topic.name), - value: isSelected, - onChanged: (bool? value) { - setState(() { - if (value == true) { - _pageSelectedTopics.add(topic); - } else { - _pageSelectedTopics.remove(topic); - } - }); - }, - ); - }, - ), - // Show loading overlay if followed topics are being fetched - if (isLoadingFollowedTopics) - Positioned.fill( - child: ColoredBox( - color: Colors.black54, // Semi-transparent overlay - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: AppSpacing.md), - Text( - l10n.headlinesFeedLoadingHeadline, - style: theme.textTheme.titleMedium?.copyWith( - color: Colors.white, - ), - ), - ], - ), - ), - ), - ), - ], - ); - }, - ), + }); + }, + ); + }, + ); + }, ), ); } From 3089c6917f3022020182e7eecb1eaff24d7e834a Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 11:27:07 +0100 Subject: [PATCH 73/94] refactor(headlines-feed): replace AccountBloc with AppBloc in feed page - Remove AccountBloc dependency from headlines_feed_page.dart - Update logic to use AppBloc for content preferences - Simplify follow/unfollow toggle functionality - Improve code readability and maintainability --- .../view/headlines_feed_page.dart | 84 +++++++++++-------- 1 file changed, 50 insertions(+), 34 deletions(-) diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index 30b5e29a..0bc27e97 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -4,7 +4,6 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_placeholder.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; @@ -302,12 +301,8 @@ class _HeadlinesFeedPageState extends State { final item = state.feedItems[index]; if (item is Headline) { - final imageStyle = context - .watch() - .state - .settings - .feedPreferences - .headlineImageStyle; + final imageStyle = + context.watch().state.headlineImageStyle; Widget tile; switch (imageStyle) { case HeadlineImageStyle.hidden: @@ -364,53 +359,74 @@ class _HeadlinesFeedPageState extends State { }, ); } else if (item is ContentCollectionItem) { - // Access AccountBloc to get the user's content preferences, + // Access AppBloc to get the user's content preferences, // which is the source of truth for followed items. - final accountState = context.watch().state; + final appState = context.watch().state; final followedTopics = - accountState.preferences?.followedTopics ?? []; + appState.userContentPreferences?.followedTopics ?? []; final followedSources = - accountState.preferences?.followedSources ?? []; + appState.userContentPreferences?.followedSources ?? []; - final followedTopicIds = followedTopics - .map((t) => t.id) - .toList(); - final followedSourceIds = followedSources - .map((s) => s.id) - .toList(); + final followedTopicIds = + followedTopics.map((t) => t.id).toList(); + final followedSourceIds = + followedSources.map((s) => s.id).toList(); return ContentCollectionDecoratorWidget( item: item, followedTopicIds: followedTopicIds, followedSourceIds: followedSourceIds, onFollowToggle: (toggledItem) { - // Determine the current following status to toggle it. - // ignore: unused_local_variable - final bool isCurrentlyFollowing; + final currentUserPreferences = + appState.userContentPreferences; + if (currentUserPreferences == null) return; + + UserContentPreferences updatedPreferences; + if (toggledItem is Topic) { - isCurrentlyFollowing = followedTopicIds.contains( - toggledItem.id, - ); - context.read().add( - AccountFollowTopicToggled(topic: toggledItem), + final isCurrentlyFollowing = + followedTopicIds.contains(toggledItem.id); + final newFollowedTopics = + List.from(followedTopics); + if (isCurrentlyFollowing) { + newFollowedTopics + .removeWhere((t) => t.id == toggledItem.id); + } else { + newFollowedTopics.add(toggledItem); + } + updatedPreferences = currentUserPreferences.copyWith( + followedTopics: newFollowedTopics, ); } else if (toggledItem is Source) { - isCurrentlyFollowing = followedSourceIds.contains( - toggledItem.id, - ); - context.read().add( - AccountFollowSourceToggled(source: toggledItem), + final isCurrentlyFollowing = + followedSourceIds.contains(toggledItem.id); + final newFollowedSources = + List.from(followedSources); + if (isCurrentlyFollowing) { + newFollowedSources + .removeWhere((s) => s.id == toggledItem.id); + } else { + newFollowedSources.add(toggledItem); + } + updatedPreferences = currentUserPreferences.copyWith( + followedSources: newFollowedSources, ); } else { return; } + + context.read().add( + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, onDismiss: (decoratorType) { context.read().add( - FeedDecoratorDismissed( - feedDecoratorType: decoratorType, - ), - ); + FeedDecoratorDismissed( + feedDecoratorType: decoratorType, + ), + ); }, ); } From f571bac2717feea4b00feb9c0a0efb1236de1289 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 11:27:16 +0100 Subject: [PATCH 74/94] refactor(headlines-feed): simplify followed filters logic - Remove unused state variables related to followed filters - Replace manual followed filters fetching with BlocBuilder - Simplify UI logic for displaying followed filters - Remove loading and error handling for followed filters --- .../view/headlines_filter_page.dart | 232 ++++-------------- 1 file changed, 52 insertions(+), 180 deletions(-) diff --git a/lib/headlines-feed/view/headlines_filter_page.dart b/lib/headlines-feed/view/headlines_filter_page.dart index 3d117358..a6314dd8 100644 --- a/lib/headlines-feed/view/headlines_filter_page.dart +++ b/lib/headlines-feed/view/headlines_filter_page.dart @@ -42,11 +42,8 @@ class _HeadlinesFilterPageState extends State { late List _tempSelectedSources; late List _tempSelectedEventCountries; - // New state variables for the "Apply my followed items" feature + /// Flag to indicate if the "Apply my followed items" filter is active. bool _useFollowedFilters = false; - bool _isLoadingFollowedFilters = false; - String? _loadFollowedFiltersError; - UserContentPreferences? _currentUserPreferences; @override void initState() { @@ -61,125 +58,6 @@ class _HeadlinesFilterPageState extends State { _tempSelectedEventCountries = List.from(currentFilter.eventCountries ?? []); _useFollowedFilters = currentFilter.isFromFollowedItems; - _isLoadingFollowedFilters = false; - _loadFollowedFiltersError = null; - _currentUserPreferences = null; - - // If the "Apply my followed items" feature is initially active, - // fetch the followed items to populate the temporary filter lists. - if (_useFollowedFilters) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _fetchAndApplyFollowedFilters(); - } - }); - } - } - - /// Fetches the user's followed items (topics, sources, countries) and - /// applies them to the temporary filter state. - /// - /// This method is called when the "Apply my followed items" toggle is - /// activated. It handles loading states, errors, and updates the UI. - Future _fetchAndApplyFollowedFilters() async { - setState(() { - _isLoadingFollowedFilters = true; - _loadFollowedFiltersError = null; - }); - - final appState = context.read().state; - final currentUser = appState.user!; - - try { - final preferencesRepo = context - .read>(); - final preferences = await preferencesRepo.read( - id: currentUser.id, - userId: currentUser.id, - ); - - // Check if followed items are empty across all categories - if (preferences.followedTopics.isEmpty && - preferences.followedSources.isEmpty && - preferences.followedCountries.isEmpty) { - setState(() { - _isLoadingFollowedFilters = false; - _useFollowedFilters = false; - _tempSelectedTopics = []; - _tempSelectedSources = []; - _tempSelectedEventCountries = []; - }); - if (mounted) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - AppLocalizationsX( - context, - ).l10n.noFollowedItemsForFilterSnackbar, - ), - duration: const Duration(seconds: 3), - ), - ); - } - return; - } else { - setState(() { - _currentUserPreferences = preferences; - _tempSelectedTopics = List.from(preferences.followedTopics); - _tempSelectedSources = List.from(preferences.followedSources); - _tempSelectedEventCountries = List.from( - preferences.followedCountries, - ); - _isLoadingFollowedFilters = false; - }); - } - } on NotFoundException { - // If user preferences are not found, treat as empty followed items. - setState(() { - _currentUserPreferences = UserContentPreferences( - id: currentUser.id, - followedTopics: const [], - followedSources: const [], - followedCountries: const [], - savedHeadlines: const [], - ); - _tempSelectedTopics = []; - _tempSelectedSources = []; - _tempSelectedEventCountries = []; - _isLoadingFollowedFilters = false; - _useFollowedFilters = false; - }); - if (mounted) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - AppLocalizationsX( - context, - ).l10n.noFollowedItemsForFilterSnackbar, - ), - duration: const Duration(seconds: 3), - ), - ); - } - } on HttpException catch (e) { - setState(() { - _isLoadingFollowedFilters = false; - _useFollowedFilters = false; - _loadFollowedFiltersError = e.message; - }); - } catch (e) { - setState(() { - _isLoadingFollowedFilters = false; - _useFollowedFilters = false; - _loadFollowedFiltersError = AppLocalizationsX( - context, - ).l10n.unknownError; - }); - } } /// Clears all temporary filter selections. @@ -243,10 +121,9 @@ class _HeadlinesFilterPageState extends State { final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); - // Determine if the "Apply my followed items" feature is active and loading. + // Determine if the "Apply my followed items" feature is active. // This will disable the individual filter tiles. - final isFollowedFilterActiveOrLoading = - _useFollowedFilters || _isLoadingFollowedFilters; + final isFollowedFilterActive = _useFollowedFilters; return Scaffold( appBar: AppBar( @@ -270,34 +147,59 @@ class _HeadlinesFilterPageState extends State { // Also reset local state for the "Apply my followed items" setState(() { _useFollowedFilters = false; - _isLoadingFollowedFilters = false; - _loadFollowedFiltersError = null; _clearTemporaryFilters(); }); context.pop(); }, ), // Apply My Followed Items Button - IconButton( - icon: _useFollowedFilters - ? const Icon(Icons.favorite) - : const Icon(Icons.favorite_border), - color: _useFollowedFilters ? theme.colorScheme.primary : null, - tooltip: l10n.headlinesFeedFilterApplyFollowedLabel, - onPressed: _isLoadingFollowedFilters - ? null // Disable while loading - : () { - setState(() { - _useFollowedFilters = !_useFollowedFilters; - if (_useFollowedFilters) { - _fetchAndApplyFollowedFilters(); - } else { - _isLoadingFollowedFilters = false; - _loadFollowedFiltersError = null; - _clearTemporaryFilters(); + BlocBuilder( + builder: (context, appState) { + final followedTopics = + appState.userContentPreferences?.followedTopics ?? []; + final followedSources = + appState.userContentPreferences?.followedSources ?? []; + final followedCountries = + appState.userContentPreferences?.followedCountries ?? []; + + final hasFollowedItems = followedTopics.isNotEmpty || + followedSources.isNotEmpty || + followedCountries.isNotEmpty; + + return IconButton( + icon: _useFollowedFilters + ? const Icon(Icons.favorite) + : const Icon(Icons.favorite_border), + color: _useFollowedFilters ? theme.colorScheme.primary : null, + tooltip: l10n.headlinesFeedFilterApplyFollowedLabel, + onPressed: hasFollowedItems + ? () { + setState(() { + _useFollowedFilters = !_useFollowedFilters; + if (_useFollowedFilters) { + _tempSelectedTopics = List.from(followedTopics); + _tempSelectedSources = List.from(followedSources); + _tempSelectedEventCountries = + List.from(followedCountries); + } else { + _clearTemporaryFilters(); + } + }); } - }); - }, + : () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + l10n.noFollowedItemsForFilterSnackbar, + ), + duration: const Duration(seconds: 3), + ), + ); + }, + ); + }, ), // Apply Filters Button IconButton( @@ -330,41 +232,11 @@ class _HeadlinesFilterPageState extends State { body: ListView( padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), children: [ - if (_isLoadingFollowedFilters) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingLarge, - vertical: AppSpacing.sm, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: AppSpacing.md), - Text(l10n.headlinesFeedLoadingHeadline), - ], - ), - ), - if (_loadFollowedFiltersError != null) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingLarge, - vertical: AppSpacing.sm, - ), - child: Text( - _loadFollowedFiltersError!, - style: TextStyle(color: theme.colorScheme.error), - ), - ), const Divider(), _buildFilterTile( context: context, title: l10n.headlinesFeedFilterTopicLabel, - enabled: !isFollowedFilterActiveOrLoading, + enabled: !isFollowedFilterActive, selectedCount: _tempSelectedTopics.length, routeName: Routes.feedFilterTopicsName, currentSelectionData: _tempSelectedTopics, @@ -375,7 +247,7 @@ class _HeadlinesFilterPageState extends State { _buildFilterTile( context: context, title: l10n.headlinesFeedFilterSourceLabel, - enabled: !isFollowedFilterActiveOrLoading, + enabled: !isFollowedFilterActive, selectedCount: _tempSelectedSources.length, routeName: Routes.feedFilterSourcesName, currentSelectionData: _tempSelectedSources, @@ -386,7 +258,7 @@ class _HeadlinesFilterPageState extends State { _buildFilterTile( context: context, title: l10n.headlinesFeedFilterEventCountryLabel, - enabled: !isFollowedFilterActiveOrLoading, + enabled: !isFollowedFilterActive, selectedCount: _tempSelectedEventCountries.length, routeName: Routes.feedFilterEventCountriesName, currentSelectionData: _tempSelectedEventCountries, From 32f57d938db3bfe914e8d5c48eac70ce284e01fa Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 11:51:26 +0100 Subject: [PATCH 75/94] chore: deleted absolete files --- .../bloc/countries_filter_bloc.dart | 79 -------- .../bloc/countries_filter_event.dart | 29 --- .../bloc/countries_filter_state.dart | 82 -------- .../bloc/sources_filter_bloc.dart | 185 ------------------ .../bloc/sources_filter_event.dart | 63 ------ .../bloc/sources_filter_state.dart | 68 ------- .../bloc/topics_filter_bloc.dart | 106 ---------- .../bloc/topics_filter_event.dart | 22 --- .../bloc/topics_filter_state.dart | 82 -------- 9 files changed, 716 deletions(-) delete mode 100644 lib/headlines-feed/bloc/countries_filter_bloc.dart delete mode 100644 lib/headlines-feed/bloc/countries_filter_event.dart delete mode 100644 lib/headlines-feed/bloc/countries_filter_state.dart delete mode 100644 lib/headlines-feed/bloc/sources_filter_bloc.dart delete mode 100644 lib/headlines-feed/bloc/sources_filter_event.dart delete mode 100644 lib/headlines-feed/bloc/sources_filter_state.dart delete mode 100644 lib/headlines-feed/bloc/topics_filter_bloc.dart delete mode 100644 lib/headlines-feed/bloc/topics_filter_event.dart delete mode 100644 lib/headlines-feed/bloc/topics_filter_state.dart diff --git a/lib/headlines-feed/bloc/countries_filter_bloc.dart b/lib/headlines-feed/bloc/countries_filter_bloc.dart deleted file mode 100644 index ae49f8e9..00000000 --- a/lib/headlines-feed/bloc/countries_filter_bloc.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'dart:async'; - -import 'package:bloc/bloc.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:core/core.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/view/country_filter_page.dart'; - -part 'countries_filter_event.dart'; -part 'countries_filter_state.dart'; - -/// {@template countries_filter_bloc} -/// Manages the state for fetching and displaying countries for filtering. -/// -/// Handles initial fetching and pagination of countries using the -/// provided [DataRepository]. -/// {@endtemplate} -class CountriesFilterBloc - extends Bloc { - /// {@macro countries_filter_bloc} - /// - /// Requires a [DataRepository] to interact with the data layer. - CountriesFilterBloc({ - required DataRepository countriesRepository, - }) : _countriesRepository = countriesRepository, - super(const CountriesFilterState()) { - on( - _onCountriesFilterRequested, - transformer: restartable(), - ); - } - - final DataRepository _countriesRepository; - - /// Handles the request to fetch countries based on a specific usage. - /// - /// This method fetches a non-paginated list of countries, filtered by - /// the provided [usage] (e.g., 'hasActiveSources', 'hasActiveHeadlines'). - Future _onCountriesFilterRequested( - CountriesFilterRequested event, - Emitter emit, - ) async { - // If already loading or successfully loaded, do not re-fetch unless explicitly - // designed for a refresh mechanism (which is not the case for this usage-based fetch). - if (state.status == CountriesFilterStatus.loading || - state.status == CountriesFilterStatus.success) { - return; - } - - emit(state.copyWith(status: CountriesFilterStatus.loading)); - - try { - // Build the filter map based on the provided usage. - final filter = event.usage != null - ? {event.usage!.name: true} - : null; - - // Fetch countries. The API for 'usage' filters is not paginated, - // so we expect a complete list. - final response = await _countriesRepository.readAll( - filter: filter, - sort: [const SortOption('name', SortOrder.asc)], - ); - - emit( - state.copyWith( - status: CountriesFilterStatus.success, - countries: response.items, - hasMore: false, // Always false for usage-based filters - cursor: null, // Always null for usage-based filters - clearError: true, - ), - ); - } on HttpException catch (e) { - emit(state.copyWith(status: CountriesFilterStatus.failure, error: e)); - } - } -} diff --git a/lib/headlines-feed/bloc/countries_filter_event.dart b/lib/headlines-feed/bloc/countries_filter_event.dart deleted file mode 100644 index 94016061..00000000 --- a/lib/headlines-feed/bloc/countries_filter_event.dart +++ /dev/null @@ -1,29 +0,0 @@ -part of 'countries_filter_bloc.dart'; - -/// {@template countries_filter_event} -/// Base class for events related to fetching and managing country filters. -/// {@endtemplate} -sealed class CountriesFilterEvent extends Equatable { - /// {@macro countries_filter_event} - const CountriesFilterEvent(); - - @override - List get props => []; -} - -/// {@template countries_filter_requested} -/// Event triggered to request the initial list of countries. -/// {@endtemplate} -final class CountriesFilterRequested extends CountriesFilterEvent { - /// {@macro countries_filter_requested} - /// - /// Optionally includes a [usage] context to filter countries by their - /// relevance to headlines (e.g., 'hasActiveSources' or 'hasActiveHeadlines'). - const CountriesFilterRequested({this.usage}); - - /// The usage context for filtering countries (e.g., 'hasActiveSources', 'hasActiveHeadlines'). - final CountryFilterUsage? usage; - - @override - List get props => [usage]; -} diff --git a/lib/headlines-feed/bloc/countries_filter_state.dart b/lib/headlines-feed/bloc/countries_filter_state.dart deleted file mode 100644 index ae6c0c1a..00000000 --- a/lib/headlines-feed/bloc/countries_filter_state.dart +++ /dev/null @@ -1,82 +0,0 @@ -part of 'countries_filter_bloc.dart'; - -/// Enum representing the different statuses of the country filter data fetching. -enum CountriesFilterStatus { - /// Initial state, no data loaded yet. - initial, - - /// Currently fetching the first page of countries. - loading, - - /// Successfully loaded countries. May be loading more in the background. - success, - - /// An error occurred while fetching countries. - failure, - - /// Loading more countries for pagination (infinity scroll). - loadingMore, -} - -/// {@template countries_filter_state} -/// Represents the state for the country filter feature. -/// -/// Contains the list of fetched countries, pagination information, -/// loading/error status. -/// {@endtemplate} -final class CountriesFilterState extends Equatable { - /// {@macro countries_filter_state} - const CountriesFilterState({ - this.status = CountriesFilterStatus.initial, - this.countries = const [], - this.hasMore = true, - this.cursor, - this.error, - }); - - /// The current status of fetching countries. - final CountriesFilterStatus status; - - /// The list of [Country] objects fetched so far. - final List countries; - - /// Flag indicating if there are more countries available to fetch. - final bool hasMore; - - /// The cursor string to fetch the next page of countries. - /// This is typically the ID of the last fetched country. - final String? cursor; - - /// An optional error object if the status is [CountriesFilterStatus.failure]. - final HttpException? error; - - /// Creates a copy of this state with the given fields replaced. - CountriesFilterState copyWith({ - CountriesFilterStatus? status, - List? countries, - bool? hasMore, - String? cursor, - HttpException? error, - bool clearError = false, - bool clearCursor = false, - }) { - return CountriesFilterState( - status: status ?? this.status, - countries: countries ?? this.countries, - hasMore: hasMore ?? this.hasMore, - // Allow explicitly setting cursor to null or clearing it - cursor: clearCursor ? null : (cursor ?? this.cursor), - // Clear error if requested, otherwise keep existing or use new one - error: clearError ? null : error ?? this.error, - ); - } - - @override - List get props => [ - status, - countries, - hasMore, - cursor, - error, - ]; -} diff --git a/lib/headlines-feed/bloc/sources_filter_bloc.dart b/lib/headlines-feed/bloc/sources_filter_bloc.dart deleted file mode 100644 index d28c7e77..00000000 --- a/lib/headlines-feed/bloc/sources_filter_bloc.dart +++ /dev/null @@ -1,185 +0,0 @@ -import 'dart:async'; - -import 'package:bloc/bloc.dart'; -import 'package:core/core.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:equatable/equatable.dart'; -part 'sources_filter_event.dart'; -part 'sources_filter_state.dart'; - -class SourcesFilterBloc extends Bloc { - SourcesFilterBloc({ - required DataRepository sourcesRepository, - required DataRepository countriesRepository, - }) : _sourcesRepository = sourcesRepository, - _countriesRepository = countriesRepository, - super(const SourcesFilterState()) { - on(_onLoadSourceFilterData); - on(_onCountryCapsuleToggled); - on(_onAllSourceTypesCapsuleToggled); - on(_onSourceTypeCapsuleToggled); - on(_onSourceCheckboxToggled); - } - - final DataRepository _sourcesRepository; - final DataRepository _countriesRepository; - - Future _onLoadSourceFilterData( - LoadSourceFilterData event, - Emitter emit, - ) async { - emit( - state.copyWith(dataLoadingStatus: SourceFilterDataLoadingStatus.loading), - ); - try { - final countriesWithActiveSources = (await _countriesRepository.readAll( - filter: {'hasActiveSources': true}, - )).items; - final initialSelectedSourceIds = event.initialSelectedSources - .map((s) => s.id) - .toSet(); - - // The initial country and source type capsule selections are ephemeral - // to the UI of the SourceFilterPage and are not passed via the event. - // They are initialized as empty sets here, meaning the filter starts - // with all countries and source types selected by default in the UI. - final initialSelectedCountryIsoCodes = {}; - final initialSelectedSourceTypes = {}; - - final allAvailableSources = (await _sourcesRepository.readAll()).items; - - // Initially, display all sources. Capsules are visually set but don't filter the list yet. - // Filtering will occur if a capsule is manually toggled. - final displayableSources = _getFilteredSources( - allSources: allAvailableSources, - selectedCountries: initialSelectedCountryIsoCodes, - selectedTypes: initialSelectedSourceTypes, - ); - - emit( - state.copyWith( - countriesWithActiveSources: countriesWithActiveSources, - allAvailableSources: allAvailableSources, - displayableSources: displayableSources, - finallySelectedSourceIds: initialSelectedSourceIds, - selectedCountryIsoCodes: initialSelectedCountryIsoCodes, - selectedSourceTypes: initialSelectedSourceTypes, - dataLoadingStatus: SourceFilterDataLoadingStatus.success, - clearErrorMessage: true, - ), - ); - } on HttpException catch (e) { - emit( - state.copyWith( - dataLoadingStatus: SourceFilterDataLoadingStatus.failure, - error: e, - ), - ); - } - } - - Future _onCountryCapsuleToggled( - CountryCapsuleToggled event, - Emitter emit, - ) async { - final currentSelected = Set.from(state.selectedCountryIsoCodes); - if (event.countryIsoCode.isEmpty) { - // "All Countries" toggled - // If "All" is tapped and it's already effectively "All" (empty set), or if it's tapped to select "All" - // we clear the set. If specific items are selected and "All" is tapped, it also clears. - // Essentially, tapping "All" always results in an empty set, meaning no country filter. - currentSelected.clear(); - } else { - // Specific country toggled - if (currentSelected.contains(event.countryIsoCode)) { - currentSelected.remove(event.countryIsoCode); - } else { - currentSelected.add(event.countryIsoCode); - } - } - final newDisplayableSources = _getFilteredSources( - allSources: state.allAvailableSources, - selectedCountries: currentSelected, - selectedTypes: state.selectedSourceTypes, - ); - emit( - state.copyWith( - selectedCountryIsoCodes: currentSelected, - displayableSources: newDisplayableSources, - ), - ); - } - - void _onAllSourceTypesCapsuleToggled( - AllSourceTypesCapsuleToggled event, - Emitter emit, - ) { - final newDisplayableSources = _getFilteredSources( - allSources: state.allAvailableSources, - selectedCountries: state.selectedCountryIsoCodes, - selectedTypes: {}, - ); - emit( - state.copyWith( - selectedSourceTypes: {}, - displayableSources: newDisplayableSources, - ), - ); - } - - void _onSourceTypeCapsuleToggled( - SourceTypeCapsuleToggled event, - Emitter emit, - ) { - final currentSelected = Set.from(state.selectedSourceTypes); - if (currentSelected.contains(event.sourceType)) { - currentSelected.remove(event.sourceType); - } else { - currentSelected.add(event.sourceType); - } - final newDisplayableSources = _getFilteredSources( - allSources: state.allAvailableSources, - selectedCountries: state.selectedCountryIsoCodes, - selectedTypes: currentSelected, - ); - emit( - state.copyWith( - selectedSourceTypes: currentSelected, - displayableSources: newDisplayableSources, - ), - ); - } - - void _onSourceCheckboxToggled( - SourceCheckboxToggled event, - Emitter emit, - ) { - final currentSelected = Set.from(state.finallySelectedSourceIds); - if (event.isSelected) { - currentSelected.add(event.sourceId); - } else { - currentSelected.remove(event.sourceId); - } - emit(state.copyWith(finallySelectedSourceIds: currentSelected)); - } - - // Helper method to filter sources based on selected countries and types - List _getFilteredSources({ - required List allSources, - required Set selectedCountries, - required Set selectedTypes, - }) { - if (selectedCountries.isEmpty && selectedTypes.isEmpty) { - return List.from(allSources); - } - - return allSources.where((source) { - final matchesCountry = - selectedCountries.isEmpty || - (selectedCountries.contains(source.headquarters.isoCode)); - final matchesType = - selectedTypes.isEmpty || (selectedTypes.contains(source.sourceType)); - return matchesCountry && matchesType; - }).toList(); - } -} diff --git a/lib/headlines-feed/bloc/sources_filter_event.dart b/lib/headlines-feed/bloc/sources_filter_event.dart deleted file mode 100644 index e5165324..00000000 --- a/lib/headlines-feed/bloc/sources_filter_event.dart +++ /dev/null @@ -1,63 +0,0 @@ -// ignore_for_file: avoid_positional_boolean_parameters - -part of 'sources_filter_bloc.dart'; - -abstract class SourcesFilterEvent extends Equatable { - const SourcesFilterEvent(); - - @override - List get props => []; -} - -/// {@template load_source_filter_data} -/// Event triggered to load the initial data for the source filter page. -/// -/// This event is dispatched when the `SourceFilterPage` is initialized. -/// It fetches all available countries and sources, and initializes the -/// internal state with any `initialSelectedSources` passed from the -/// `HeadlinesFilterPage`. The country and source type capsule selections -/// are ephemeral to the `SourceFilterPage` and are not passed via this event. -/// {@endtemplate} -class LoadSourceFilterData extends SourcesFilterEvent { - /// {@macro load_source_filter_data} - const LoadSourceFilterData({this.initialSelectedSources = const []}); - - /// The list of sources that were initially selected on the previous page. - final List initialSelectedSources; - - @override - List get props => [initialSelectedSources]; -} - -class CountryCapsuleToggled extends SourcesFilterEvent { - const CountryCapsuleToggled(this.countryIsoCode); - - /// If countryIsoCode is empty, it implies "All Countries". - final String countryIsoCode; - - @override - List get props => [countryIsoCode]; -} - -class AllSourceTypesCapsuleToggled extends SourcesFilterEvent { - const AllSourceTypesCapsuleToggled(); -} - -class SourceTypeCapsuleToggled extends SourcesFilterEvent { - const SourceTypeCapsuleToggled(this.sourceType); - - final SourceType sourceType; - - @override - List get props => [sourceType]; -} - -class SourceCheckboxToggled extends SourcesFilterEvent { - const SourceCheckboxToggled(this.sourceId, this.isSelected); - - final String sourceId; - final bool isSelected; - - @override - List get props => [sourceId, isSelected]; -} diff --git a/lib/headlines-feed/bloc/sources_filter_state.dart b/lib/headlines-feed/bloc/sources_filter_state.dart deleted file mode 100644 index e16ba901..00000000 --- a/lib/headlines-feed/bloc/sources_filter_state.dart +++ /dev/null @@ -1,68 +0,0 @@ -part of 'sources_filter_bloc.dart'; - -enum SourceFilterDataLoadingStatus { initial, loading, success, failure } - -class SourcesFilterState extends Equatable { - const SourcesFilterState({ - this.countriesWithActiveSources = const [], - this.selectedCountryIsoCodes = const {}, - this.availableSourceTypes = SourceType.values, - this.selectedSourceTypes = const {}, - this.allAvailableSources = const [], - this.displayableSources = const [], - this.finallySelectedSourceIds = const {}, - this.dataLoadingStatus = SourceFilterDataLoadingStatus.initial, - this.error, - }); - - final List countriesWithActiveSources; - final Set selectedCountryIsoCodes; - final List availableSourceTypes; - final Set selectedSourceTypes; - final List allAvailableSources; - final List displayableSources; - final Set finallySelectedSourceIds; - final SourceFilterDataLoadingStatus dataLoadingStatus; - final HttpException? error; - - SourcesFilterState copyWith({ - List? countriesWithActiveSources, - Set? selectedCountryIsoCodes, - List? availableSourceTypes, - Set? selectedSourceTypes, - List? allAvailableSources, - List? displayableSources, - Set? finallySelectedSourceIds, - SourceFilterDataLoadingStatus? dataLoadingStatus, - HttpException? error, - bool clearErrorMessage = false, - }) { - return SourcesFilterState( - countriesWithActiveSources: - countriesWithActiveSources ?? this.countriesWithActiveSources, - selectedCountryIsoCodes: - selectedCountryIsoCodes ?? this.selectedCountryIsoCodes, - availableSourceTypes: availableSourceTypes ?? this.availableSourceTypes, - selectedSourceTypes: selectedSourceTypes ?? this.selectedSourceTypes, - allAvailableSources: allAvailableSources ?? this.allAvailableSources, - displayableSources: displayableSources ?? this.displayableSources, - finallySelectedSourceIds: - finallySelectedSourceIds ?? this.finallySelectedSourceIds, - dataLoadingStatus: dataLoadingStatus ?? this.dataLoadingStatus, - error: clearErrorMessage ? null : error ?? this.error, - ); - } - - @override - List get props => [ - countriesWithActiveSources, - selectedCountryIsoCodes, - availableSourceTypes, - selectedSourceTypes, - allAvailableSources, - displayableSources, - finallySelectedSourceIds, - dataLoadingStatus, - error, - ]; -} diff --git a/lib/headlines-feed/bloc/topics_filter_bloc.dart b/lib/headlines-feed/bloc/topics_filter_bloc.dart deleted file mode 100644 index e733d98a..00000000 --- a/lib/headlines-feed/bloc/topics_filter_bloc.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'dart:async'; - -import 'package:bloc/bloc.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:core/core.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:equatable/equatable.dart'; -part 'topics_filter_event.dart'; -part 'topics_filter_state.dart'; - -/// {@template topics_filter_bloc} -/// Manages the state for fetching and displaying topics for filtering. -/// -/// Handles initial fetching and pagination of topics using the -/// provided [DataRepository]. -/// {@endtemplate} -class TopicsFilterBloc extends Bloc { - /// {@macro topics_filter_bloc} - /// - /// Requires a [DataRepository] to interact with the data layer. - TopicsFilterBloc({ - required DataRepository topicsRepository, - }) : _topicsRepository = topicsRepository, - super(const TopicsFilterState()) { - on( - _onTopicsFilterRequested, - transformer: restartable(), - ); - on( - _onTopicsFilterLoadMoreRequested, - transformer: droppable(), - ); - } - - final DataRepository _topicsRepository; - - /// Number of topics to fetch per page. - static const _topicsLimit = 20; - - /// Handles the initial request to fetch topics. - Future _onTopicsFilterRequested( - TopicsFilterRequested event, - Emitter emit, - ) async { - // Prevent fetching if already loading or successful (unless forced refresh) - if (state.status == TopicsFilterStatus.loading || - state.status == TopicsFilterStatus.success) { - return; - } - - emit(state.copyWith(status: TopicsFilterStatus.loading)); - - try { - final response = await _topicsRepository.readAll( - pagination: const PaginationOptions(limit: _topicsLimit), - sort: [const SortOption('name', SortOrder.asc)], - ); - emit( - state.copyWith( - status: TopicsFilterStatus.success, - topics: response.items, - hasMore: response.hasMore, - cursor: response.cursor, - clearError: true, - ), - ); - } on HttpException catch (e) { - emit(state.copyWith(status: TopicsFilterStatus.failure, error: e)); - } - } - - /// Handles the request to load more topics for pagination. - Future _onTopicsFilterLoadMoreRequested( - TopicsFilterLoadMoreRequested event, - Emitter emit, - ) async { - // Only proceed if currently successful and has more items - if (state.status != TopicsFilterStatus.success || !state.hasMore) { - return; - } - - emit(state.copyWith(status: TopicsFilterStatus.loadingMore)); - - try { - final response = await _topicsRepository.readAll( - pagination: PaginationOptions( - limit: _topicsLimit, - cursor: state.cursor, - ), - sort: [const SortOption('name', SortOrder.asc)], - ); - emit( - state.copyWith( - status: TopicsFilterStatus.success, - // Append new topics to the existing list - topics: List.of(state.topics)..addAll(response.items), - hasMore: response.hasMore, - cursor: response.cursor, - ), - ); - } on HttpException catch (e) { - // Keep existing data but indicate failure - emit(state.copyWith(status: TopicsFilterStatus.failure, error: e)); - } - } -} diff --git a/lib/headlines-feed/bloc/topics_filter_event.dart b/lib/headlines-feed/bloc/topics_filter_event.dart deleted file mode 100644 index 14d08ed7..00000000 --- a/lib/headlines-feed/bloc/topics_filter_event.dart +++ /dev/null @@ -1,22 +0,0 @@ -part of 'topics_filter_bloc.dart'; - -/// {@template topics_filter_event} -/// Base class for events related to fetching and managing topic filters. -/// {@endtemplate} -sealed class TopicsFilterEvent extends Equatable { - /// {@macro topics_filter_event} - const TopicsFilterEvent(); - - @override - List get props => []; -} - -/// {@template topics_filter_requested} -/// Event triggered to request the initial list of topics. -/// {@endtemplate} -final class TopicsFilterRequested extends TopicsFilterEvent {} - -/// {@template topics_filter_load_more_requested} -/// Event triggered to request the next page of topics for pagination. -/// {@endtemplate} -final class TopicsFilterLoadMoreRequested extends TopicsFilterEvent {} diff --git a/lib/headlines-feed/bloc/topics_filter_state.dart b/lib/headlines-feed/bloc/topics_filter_state.dart deleted file mode 100644 index a8903cce..00000000 --- a/lib/headlines-feed/bloc/topics_filter_state.dart +++ /dev/null @@ -1,82 +0,0 @@ -part of 'topics_filter_bloc.dart'; - -/// Enum representing the different statuses of the topic filter data fetching. -enum TopicsFilterStatus { - /// Initial state, no data loaded yet. - initial, - - /// Currently fetching the first page of topics. - loading, - - /// Successfully loaded topics. May be loading more in the background. - success, - - /// An error occurred while fetching topics. - failure, - - /// Loading more topics for pagination (infinity scroll). - loadingMore, -} - -/// {@template topics_filter_state} -/// Represents the state for the topic filter feature. -/// -/// Contains the list of fetched topics, pagination information, -/// loading/error status. -/// {@endtemplate} -final class TopicsFilterState extends Equatable { - /// {@macro topics_filter_state} - const TopicsFilterState({ - this.status = TopicsFilterStatus.initial, - this.topics = const [], - this.hasMore = true, - this.cursor, - this.error, - }); - - /// The current status of fetching topics. - final TopicsFilterStatus status; - - /// The list of [Topic] objects fetched so far. - final List topics; - - /// Flag indicating if there are more topics available to fetch. - final bool hasMore; - - /// The cursor string to fetch the next page of topics. - /// This is typically the ID of the last fetched topic. - final String? cursor; - - /// An optional error object if the status is [TopicsFilterStatus.failure]. - final HttpException? error; - - /// Creates a copy of this state with the given fields replaced. - TopicsFilterState copyWith({ - TopicsFilterStatus? status, - List? topics, - bool? hasMore, - String? cursor, - HttpException? error, - bool clearError = false, - bool clearCursor = false, - }) { - return TopicsFilterState( - status: status ?? this.status, - topics: topics ?? this.topics, - hasMore: hasMore ?? this.hasMore, - // Allow explicitly setting cursor to null or clearing it - cursor: clearCursor ? null : (cursor ?? this.cursor), - // Clear error if requested, otherwise keep existing or use new one - error: clearError ? null : error ?? this.error, - ); - } - - @override - List get props => [ - status, - topics, - hasMore, - cursor, - error, - ]; -} From ed98b8f79ac040fb21cce098e8d41f8d97449ef2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 11:51:44 +0100 Subject: [PATCH 76/94] feat(headlines-feed): implement filter event classes - Define base HeadlinesFilterEvent class - Add FilterDataLoaded event for initial filter data load - Implement FilterTopicToggled, FilterSourceToggled, and FilterCountryToggled events for checkbox toggles - Add FollowedItemsFilterToggled event for followed items toggle - Implement FilterSelectionsCleared event to clear all filters --- .../bloc/headlines_filter_event.dart | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 lib/headlines-feed/bloc/headlines_filter_event.dart diff --git a/lib/headlines-feed/bloc/headlines_filter_event.dart b/lib/headlines-feed/bloc/headlines_filter_event.dart new file mode 100644 index 00000000..ebb3066f --- /dev/null +++ b/lib/headlines-feed/bloc/headlines_filter_event.dart @@ -0,0 +1,120 @@ +part of 'headlines_filter_bloc.dart'; + +/// {@template headlines_filter_event} +/// Base class for all events in the [HeadlinesFilterBloc]. +/// {@endtemplate} +sealed class HeadlinesFilterEvent extends Equatable { + /// {@macro headlines_filter_event} + const HeadlinesFilterEvent(); + + @override + List get props => []; +} + +/// {@template filter_data_loaded} +/// Event triggered to load all initial filter data (topics, sources, countries). +/// +/// This event is dispatched when the filter page is initialized. +/// {@endtemplate} +final class FilterDataLoaded extends HeadlinesFilterEvent { + /// {@macro filter_data_loaded} + const FilterDataLoaded({ + this.initialSelectedTopics = const [], + this.initialSelectedSources = const [], + this.initialSelectedCountries = const [], + this.isUsingFollowedItems = false, + }); + + /// The topics that were initially selected on the previous page. + final List initialSelectedTopics; + + /// The sources that were initially selected on the previous page. + final List initialSelectedSources; + + /// The countries that were initially selected on the previous page. + final List initialSelectedCountries; + + /// Whether the filter is initially set to use followed items. + final bool isUsingFollowedItems; + + @override + List get props => [ + initialSelectedTopics, + initialSelectedSources, + initialSelectedCountries, + isUsingFollowedItems, + ]; +} + +/// {@template filter_topic_toggled} +/// Event triggered when a topic checkbox is toggled. +/// {@endtemplate} +final class FilterTopicToggled extends HeadlinesFilterEvent { + /// {@macro filter_topic_toggled} + const FilterTopicToggled({required this.topic, required this.isSelected}); + + /// The [Topic] that was toggled. + final Topic topic; + + /// The new selection state of the topic. + final bool isSelected; + + @override + List get props => [topic, isSelected]; +} + +/// {@template filter_source_toggled} +/// Event triggered when a source checkbox is toggled. +/// {@endtemplate} +final class FilterSourceToggled extends HeadlinesFilterEvent { + /// {@macro filter_source_toggled} + const FilterSourceToggled({required this.source, required this.isSelected}); + + /// The [Source] that was toggled. + final Source source; + + /// The new selection state of the source. + final bool isSelected; + + @override + List get props => [source, isSelected]; +} + +/// {@template filter_country_toggled} +/// Event triggered when a country checkbox is toggled. +/// {@endtemplate} +final class FilterCountryToggled extends HeadlinesFilterEvent { + /// {@macro filter_country_toggled} + const FilterCountryToggled({required this.country, required this.isSelected}); + + /// The [Country] that was toggled. + final Country country; + + /// The new selection state of the country. + final bool isSelected; + + @override + List get props => [country, isSelected]; +} + +/// {@template followed_items_filter_toggled} +/// Event triggered when the "Apply my followed items" button is toggled. +/// {@endtemplate} +final class FollowedItemsFilterToggled extends HeadlinesFilterEvent { + /// {@macro followed_items_filter_toggled} + const FollowedItemsFilterToggled({required this.isUsingFollowedItems}); + + /// The new state of the "Apply my followed items" toggle. + final bool isUsingFollowedItems; + + @override + List get props => [isUsingFollowedItems]; +} + +/// {@template filter_selections_cleared} +/// Event triggered to clear all active filter selections. +/// {@endtemplate} +final class FilterSelectionsCleared extends HeadlinesFilterEvent { + /// {@macro filter_selections_cleared} + const FilterSelectionsCleared(); +} From ddb78528504084bc20e7ab0ee8f6471d779194ae Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 11:51:57 +0100 Subject: [PATCH 77/94] feat(headlines-feed): implement HeadlinesFilterState for filter feature - Define HeadlinesFilterStatus enum for different fetching statuses - Create HeadlinesFilterState class to hold filter options and selections - Include properties for topics, sources, countries, and followed items - Implement copyWith method for state immutability - Add error handling for failure status --- .../bloc/headlines_filter_state.dart | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 lib/headlines-feed/bloc/headlines_filter_state.dart diff --git a/lib/headlines-feed/bloc/headlines_filter_state.dart b/lib/headlines-feed/bloc/headlines_filter_state.dart new file mode 100644 index 00000000..8192ae93 --- /dev/null +++ b/lib/headlines-feed/bloc/headlines_filter_state.dart @@ -0,0 +1,103 @@ +part of 'headlines_filter_bloc.dart'; + +/// Enum representing the different statuses of the filter data fetching. +enum HeadlinesFilterStatus { + /// Initial state, no data loaded yet. + initial, + + /// Currently loading all filter data (topics, sources, countries). + loading, + + /// Successfully loaded all filter data. + success, + + /// An error occurred while fetching filter data. + failure, +} + +/// {@template headlines_filter_state} +/// Represents the state for the centralized headlines filter feature. +/// +/// This state holds all available filter options (topics, sources, countries) +/// and the user's current temporary selections, along with loading/error status. +/// {@endtemplate} +final class HeadlinesFilterState extends Equatable { + /// {@macro headlines_filter_state} + const HeadlinesFilterState({ + this.status = HeadlinesFilterStatus.initial, + this.allTopics = const [], + this.allSources = const [], + this.allCountries = const [], + this.selectedTopics = const {}, + this.selectedSources = const {}, + this.selectedCountries = const {}, + this.isUsingFollowedItems = false, + this.error, + }); + + /// The current status of fetching filter data. + final HeadlinesFilterStatus status; + + /// All available [Topic] objects that can be used for filtering. + final List allTopics; + + /// All available [Source] objects that can be used for filtering. + final List allSources; + + /// All available [Country] objects that can be used for filtering. + final List allCountries; + + /// The set of [Topic] objects currently selected by the user. + final Set selectedTopics; + + /// The set of [Source] objects currently selected by the user. + final Set selectedSources; + + /// The set of [Country] objects currently selected by the user. + final Set selectedCountries; + + /// Flag indicating if the filter is currently set to "Apply my followed items". + final bool isUsingFollowedItems; + + /// An optional error object if the status is [HeadlinesFilterStatus.failure]. + final HttpException? error; + + /// Creates a copy of this state with the given fields replaced. + HeadlinesFilterState copyWith({ + HeadlinesFilterStatus? status, + List? allTopics, + List? allSources, + List? allCountries, + Set? selectedTopics, + Set? selectedSources, + Set? selectedCountries, + bool? isUsingFollowedItems, + HttpException? error, + bool clearError = false, + }) { + return HeadlinesFilterState( + status: status ?? this.status, + allTopics: allTopics ?? this.allTopics, + allSources: allSources ?? this.allSources, + allCountries: allCountries ?? this.allCountries, + selectedTopics: selectedTopics ?? this.selectedTopics, + selectedSources: selectedSources ?? this.selectedSources, + selectedCountries: selectedCountries ?? this.selectedCountries, + isUsingFollowedItems: isUsingFollowedItems ?? this.isUsingFollowedItems, + error: clearError ? null : error ?? this.error, + ); + } + + @override + List get props => [ + status, + allTopics, + allSources, + allCountries, + selectedTopics, + selectedSources, + selectedCountries, + isUsingFollowedItems, + error, + ]; +} From b381539a1ce6ef878cf072d9ec127d79c8c55032 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 11:52:33 +0100 Subject: [PATCH 78/94] feat(headlines-feed): implement HeadlinesFilterBloc - Add new BLoC for managing centralized headlines filter feature - Implement logic for fetching filter options (topics, sources, countries) - Handle user interactions with filter UI - Integrate with AppBloc for user content preferences - Add event handlers for various filter-related actions --- .../bloc/headlines_filter_bloc.dart | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 lib/headlines-feed/bloc/headlines_filter_bloc.dart diff --git a/lib/headlines-feed/bloc/headlines_filter_bloc.dart b/lib/headlines-feed/bloc/headlines_filter_bloc.dart new file mode 100644 index 00000000..6343f4a4 --- /dev/null +++ b/lib/headlines-feed/bloc/headlines_filter_bloc.dart @@ -0,0 +1,207 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:logging/logging.dart'; + +part 'headlines_filter_event.dart'; +part 'headlines_filter_state.dart'; + +/// {@template headlines_filter_bloc} +/// Manages the state for the centralized headlines filter feature. +/// +/// This BLoC is responsible for fetching all available filter options +/// (topics, sources, countries) and managing the user's temporary selections +/// as they interact with the filter UI. It also integrates with the [AppBloc] +/// to access user-specific content preferences for "followed items" functionality. +/// {@endtemplate} +class HeadlinesFilterBloc extends Bloc { + /// {@macro headlines_filter_bloc} + /// + /// Requires repositories for topics, sources, and countries, as well as + /// the [AppBloc] to access user content preferences. + HeadlinesFilterBloc({ + required DataRepository topicsRepository, + required DataRepository sourcesRepository, + required DataRepository countriesRepository, + required AppBloc appBloc, + }) : _topicsRepository = topicsRepository, + _sourcesRepository = sourcesRepository, + _countriesRepository = countriesRepository, + _appBloc = appBloc, + _logger = Logger('HeadlinesFilterBloc'), + super(const HeadlinesFilterState()) { + on(_onFilterDataLoaded, transformer: restartable()); + on(_onFilterTopicToggled); + on(_onFilterSourceToggled); + on(_onFilterCountryToggled); + on(_onFollowedItemsFilterToggled); + on(_onFilterSelectionsCleared); + } + + final DataRepository _topicsRepository; + final DataRepository _sourcesRepository; + final DataRepository _countriesRepository; + final AppBloc _appBloc; + final Logger _logger; + + /// Handles the [FilterDataLoaded] event, fetching all necessary filter data. + /// + /// This method fetches all available topics, sources, and countries. + /// It also initializes the selected items based on the `initialSelected` + /// lists provided in the event, typically from the current filter state + /// of the `HeadlinesFeedBloc`. + Future _onFilterDataLoaded( + FilterDataLoaded event, + Emitter emit, + ) async { + if (state.status == HeadlinesFilterStatus.loading || + state.status == HeadlinesFilterStatus.success) { + return; + } + + emit(state.copyWith(status: HeadlinesFilterStatus.loading)); + + try { + final allTopicsResponse = await _topicsRepository.readAll( + sort: [const SortOption('name', SortOrder.asc)], + ); + final allSourcesResponse = await _sourcesRepository.readAll( + sort: [const SortOption('name', SortOrder.asc)], + ); + final allCountriesResponse = await _countriesRepository.readAll( + filter: {'hasActiveSources': true}, // Only countries with active sources + sort: [const SortOption('name', SortOrder.asc)], + ); + + emit( + state.copyWith( + status: HeadlinesFilterStatus.success, + allTopics: allTopicsResponse.items, + allSources: allSourcesResponse.items, + allCountries: allCountriesResponse.items, + selectedTopics: Set.from(event.initialSelectedTopics), + selectedSources: Set.from(event.initialSelectedSources), + selectedCountries: Set.from(event.initialSelectedCountries), + isUsingFollowedItems: event.isUsingFollowedItems, + clearError: true, + ), + ); + } on HttpException catch (e) { + _logger.severe('Failed to load filter data (HttpException): $e'); + emit(state.copyWith(status: HeadlinesFilterStatus.failure, error: e)); + } catch (e, s) { + _logger.severe('Unexpected error loading filter data.', e, s); + emit( + state.copyWith( + status: HeadlinesFilterStatus.failure, + error: UnknownException(e.toString()), + ), + ); + } + } + + /// Handles the [FilterTopicToggled] event, updating the selected topics. + void _onFilterTopicToggled( + FilterTopicToggled event, + Emitter emit, + ) { + final updatedSelectedTopics = Set.from(state.selectedTopics); + if (event.isSelected) { + updatedSelectedTopics.add(event.topic); + } else { + updatedSelectedTopics.remove(event.topic); + } + emit( + state.copyWith( + selectedTopics: updatedSelectedTopics, + isUsingFollowedItems: false, // Toggling individual item clears followed filter + ), + ); + } + + /// Handles the [FilterSourceToggled] event, updating the selected sources. + void _onFilterSourceToggled( + FilterSourceToggled event, + Emitter emit, + ) { + final updatedSelectedSources = Set.from(state.selectedSources); + if (event.isSelected) { + updatedSelectedSources.add(event.source); + } else { + updatedSelectedSources.remove(event.source); + } + emit( + state.copyWith( + selectedSources: updatedSelectedSources, + isUsingFollowedItems: false, // Toggling individual item clears followed filter + ), + ); + } + + /// Handles the [FilterCountryToggled] event, updating the selected countries. + void _onFilterCountryToggled( + FilterCountryToggled event, + Emitter emit, + ) { + final updatedSelectedCountries = Set.from(state.selectedCountries); + if (event.isSelected) { + updatedSelectedCountries.add(event.country); + } else { + updatedSelectedCountries.remove(event.country); + } + emit( + state.copyWith( + selectedCountries: updatedSelectedCountries, + isUsingFollowedItems: false, // Toggling individual item clears followed filter + ), + ); + } + + /// Handles the [FollowedItemsFilterToggled] event, applying or clearing + /// followed items as filters. + void _onFollowedItemsFilterToggled( + FollowedItemsFilterToggled event, + Emitter emit, + ) { + if (event.isUsingFollowedItems) { + final userPreferences = _appBloc.state.userContentPreferences; + emit( + state.copyWith( + selectedTopics: Set.from(userPreferences?.followedTopics ?? []), + selectedSources: Set.from(userPreferences?.followedSources ?? []), + selectedCountries: Set.from(userPreferences?.followedCountries ?? []), + isUsingFollowedItems: true, + ), + ); + } else { + emit( + state.copyWith( + selectedTopics: {}, + selectedSources: {}, + selectedCountries: {}, + isUsingFollowedItems: false, + ), + ); + } + } + + /// Handles the [FilterSelectionsCleared] event, clearing all filter selections. + void _onFilterSelectionsCleared( + FilterSelectionsCleared event, + Emitter emit, + ) { + emit( + state.copyWith( + selectedTopics: {}, + selectedSources: {}, + selectedCountries: {}, + isUsingFollowedItems: false, + ), + ); + } +} From 7dc5dceadab533f615b07fbad18b199c9e844612 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 11:53:19 +0100 Subject: [PATCH 79/94] refactor(headlines-feed): migrate CountryFilterPage to HeadlinesFilterBloc - Replace CountriesFilterBloc with HeadlinesFilterBloc for centralized state management - Convert CountryFilterPage from StatefulWidget to StatelessWidget - Remove local selection state and rely on HeadlinesFilterBloc for country selections - Update UI to reflect changes in state management logic - Simplify page navigation and data handling by integrating with HeadlinesFilterBloc --- .../view/country_filter_page.dart | 151 ++++++------------ 1 file changed, 53 insertions(+), 98 deletions(-) diff --git a/lib/headlines-feed/view/country_filter_page.dart b/lib/headlines-feed/view/country_filter_page.dart index 4301e0f4..849f22fb 100644 --- a/lib/headlines-feed/view/country_filter_page.dart +++ b/lib/headlines-feed/view/country_filter_page.dart @@ -2,80 +2,23 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/countries_filter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_filter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; -import 'package:go_router/go_router.dart'; import 'package:ui_kit/ui_kit.dart'; -enum CountryFilterUsage { - // countries with active sources - hasActiveSources, - - // countries with active headlines - hasActiveHeadlines, -} - /// {@template country_filter_page} /// A page dedicated to selecting event countries for filtering headlines. /// -/// Uses [CountriesFilterBloc] to fetch countries paginatively, allows multiple -/// selections, and returns the selected list via `context.pop` when the user -/// applies the changes. +/// This page now interacts with the centralized [HeadlinesFilterBloc] +/// to manage the list of available countries and the user's selections. /// {@endtemplate} -class CountryFilterPage extends StatefulWidget { +class CountryFilterPage extends StatelessWidget { /// {@macro country_filter_page} - const CountryFilterPage({required this.title, this.filter, super.key}); + const CountryFilterPage({required this.title, super.key}); /// The title to display in the app bar for this filter page. final String title; - /// The usage context for filtering countries (e.g., 'hasActiveSources', 'hasActiveHeadlines'). - /// If null, fetches all countries (though this is not the primary use case for this page). - final CountryFilterUsage? filter; - - @override - State createState() => _CountryFilterPageState(); -} - -/// State for the [CountryFilterPage]. -/// -/// Manages the local selection state ([_pageSelectedCountries]) and interacts -/// with [CountriesFilterBloc] for data fetching. -class _CountryFilterPageState extends State { - /// Stores the countries selected by the user *on this specific page*. - /// This state is local to the `CountryFilterPage` lifecycle. - /// It's initialized in `initState` using the list of previously selected - /// countries passed via the `extra` parameter during navigation from - /// `HeadlinesFilterPage`. This ensures the checkboxes reflect the state - /// from the main filter page when this page loads. - late Set _pageSelectedCountries; - - @override - void initState() { - super.initState(); - - // Initialization needs to happen after the first frame to safely access - // GoRouterState.of(context). - WidgetsBinding.instance.addPostFrameCallback((_) { - // 1. Retrieve the list of countries that were already selected on the - // previous page (HeadlinesFilterPage). This list is passed dynamically - // via the `extra` parameter in the `context.pushNamed` call. - final initialSelection = - GoRouterState.of(context).extra as List?; - - // 2. Initialize the local selection state (`_pageSelectedCountries`) for this - // page. Use a Set for efficient add/remove/contains operations. - // This ensures the checkboxes on this page are initially checked - // correctly based on the selections made previously. - _pageSelectedCountries = Set.from(initialSelection ?? []); - }); - } - - @override - void dispose() { - super.dispose(); - } - @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; @@ -85,19 +28,22 @@ class _CountryFilterPageState extends State { return Scaffold( appBar: AppBar( title: Text( - widget.title, // Use the dynamic title + title, // Use the dynamic title style: textTheme.titleLarge, ), actions: [ // Apply My Followed Countries Button - BlocBuilder( - builder: (context, appState) { + BlocBuilder( + builder: (context, filterState) { + final appState = context.watch().state; final followedCountries = appState.userContentPreferences?.followedCountries ?? []; - final isFollowedFilterActive = - followedCountries.isNotEmpty && - _pageSelectedCountries.length == followedCountries.length && - _pageSelectedCountries.containsAll(followedCountries); + + // Determine if the current selection matches the followed countries + final isFollowedFilterActive = followedCountries.isNotEmpty && + filterState.selectedCountries.length == + followedCountries.length && + filterState.selectedCountries.containsAll(followedCountries); return IconButton( icon: isFollowedFilterActive @@ -108,9 +54,6 @@ class _CountryFilterPageState extends State { : null, tooltip: l10n.headlinesFeedFilterApplyFollowedLabel, onPressed: () { - setState(() { - _pageSelectedCountries = Set.from(followedCountries); - }); if (followedCountries.isEmpty) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() @@ -120,29 +63,35 @@ class _CountryFilterPageState extends State { duration: const Duration(seconds: 3), ), ); + } else { + // Toggle the followed items filter in the HeadlinesFilterBloc + context.read().add( + FollowedItemsFilterToggled( + isUsingFollowedItems: !isFollowedFilterActive, + ), + ); } }, ); }, ), + // Apply Filters Button (now just pops, as state is managed centrally) IconButton( icon: const Icon(Icons.check), tooltip: l10n.headlinesFeedFilterApplyButton, onPressed: () { - // When the user taps 'Apply' (checkmark), pop the current route - // and return the final list of selected countries (`_pageSelectedCountries`) - // from this page back to the previous page (`HeadlinesFilterPage`). - // `HeadlinesFilterPage` receives this list in its `onResult` callback. - context.pop(_pageSelectedCountries.toList()); + // The selections are already managed by HeadlinesFilterBloc. + // Just pop the page. + Navigator.of(context).pop(); }, ), ], ), - body: BlocBuilder( - builder: (context, state) { + body: BlocBuilder( + builder: (context, filterState) { // Determine overall loading status for the main list final isLoadingMainList = - state.status == CountriesFilterStatus.loading; + filterState.status == HeadlinesFilterStatus.loading; // Handle initial loading state if (isLoadingMainList) { @@ -154,19 +103,24 @@ class _CountryFilterPageState extends State { } // Handle failure state (show error and retry button) - if (state.status == CountriesFilterStatus.failure && - state.countries.isEmpty) { + if (filterState.status == HeadlinesFilterStatus.failure && + filterState.allCountries.isEmpty) { return FailureStateWidget( - exception: state.error ?? const UnknownException('Unknown error'), - onRetry: () => context.read().add( - CountriesFilterRequested(usage: widget.filter), - ), + exception: + filterState.error ?? const UnknownException('Unknown error'), + onRetry: () => context.read().add( + FilterDataLoaded( + initialSelectedTopics: filterState.selectedTopics.toList(), + initialSelectedSources: filterState.selectedSources.toList(), + initialSelectedCountries: filterState.selectedCountries.toList(), + isUsingFollowedItems: filterState.isUsingFollowedItems, + ), + ), ); } // Handle empty state (after successful load but no countries found) - if (state.status == CountriesFilterStatus.success && - state.countries.isEmpty) { + if (filterState.allCountries.isEmpty) { return InitialStateWidget( icon: Icons.flag_circle_outlined, headline: l10n.countryFilterEmptyHeadline, @@ -179,10 +133,10 @@ class _CountryFilterPageState extends State { padding: const EdgeInsets.symmetric( vertical: AppSpacing.paddingSmall, ).copyWith(bottom: AppSpacing.xxl), - itemCount: state.countries.length, + itemCount: filterState.allCountries.length, itemBuilder: (context, index) { - final country = state.countries[index]; - final isSelected = _pageSelectedCountries.contains(country); + final country = filterState.allCountries[index]; + final isSelected = filterState.selectedCountries.contains(country); return CheckboxListTile( title: Text(country.name, style: textTheme.titleMedium), @@ -217,13 +171,14 @@ class _CountryFilterPageState extends State { ), value: isSelected, onChanged: (bool? value) { - setState(() { - if (value == true) { - _pageSelectedCountries.add(country); - } else { - _pageSelectedCountries.remove(country); - } - }); + if (value != null) { + context.read().add( + FilterCountryToggled( + country: country, + isSelected: value, + ), + ); + } }, controlAffinity: ListTileControlAffinity.leading, contentPadding: const EdgeInsets.symmetric( From 800b77ffcf15882f552a68a14422b1a1d6782fe2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 11:53:33 +0100 Subject: [PATCH 80/94] refactor(headlines-feed): remove unnecessary code in headlines feed page - Remove unused headlinesFeedBloc variable - Remove extra parameter when navigating to filter page --- lib/headlines-feed/view/headlines_feed_page.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index 0bc27e97..8a0c1618 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -147,11 +147,8 @@ class _HeadlinesFeedPageState extends State { tooltip: l10n.headlinesFeedFilterTooltip, onPressed: () { // Navigate to the filter page route - final headlinesFeedBloc = context - .read(); context.goNamed( Routes.feedFilterName, - extra: headlinesFeedBloc, ); }, ), From 9e0ac84a5fae522759e7b9259fef4af242ec7668 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 11:53:57 +0100 Subject: [PATCH 81/94] refactor(headlines-feed): migrate SourceFilterPage to HeadlinesFilterBloc - Replace SourcesFilterBloc with HeadlinesFilterBloc for state management - Remove local state and data loading logic from SourceFilterPage - Update UI to reflect new state management approach - Improve performance and reduce complexity --- .../view/source_filter_page.dart | 251 +++++++++++------- 1 file changed, 152 insertions(+), 99 deletions(-) diff --git a/lib/headlines-feed/view/source_filter_page.dart b/lib/headlines-feed/view/source_filter_page.dart index 5d5a2ea1..c98cbfbb 100644 --- a/lib/headlines-feed/view/source_filter_page.dart +++ b/lib/headlines-feed/view/source_filter_page.dart @@ -1,46 +1,29 @@ // ignore_for_file: lines_longer_than_80_chars import 'package:core/core.dart'; -import 'package:data_repository/data_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/sources_filter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_filter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:ui_kit/ui_kit.dart'; -// Keys are defined in headlines_filter_page.dart and imported by router.dart /// {@template source_filter_page} /// A page dedicated to selecting news sources for filtering headlines. /// /// This page allows users to refine the displayed list of sources using -/// country and source type capsules. However, these internal UI filter -/// selections (country and source type) are *not* returned or persisted -/// outside this page. Its sole purpose is to return the list of -/// *explicitly checked* [Source] items. +/// country and source type capsules. It now interacts with the centralized +/// [HeadlinesFilterBloc] to manage the list of available sources and the +/// user's selections. /// {@endtemplate} class SourceFilterPage extends StatelessWidget { /// {@macro source_filter_page} - const SourceFilterPage({super.key, this.initialSelectedSources = const []}); - - /// The list of sources that were initially selected on the previous page. - final List initialSelectedSources; + const SourceFilterPage({super.key}); @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => SourcesFilterBloc( - sourcesRepository: context.read>(), - countriesRepository: context.read>(), - appBloc: context.read(), - )..add( - LoadSourceFilterData( - initialSelectedSources: initialSelectedSources, - ), - ), - child: const _SourceFilterView(), - ); + return const _SourceFilterView(); } } @@ -52,7 +35,6 @@ class _SourceFilterView extends StatelessWidget { final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; - final state = context.watch().state; return Scaffold( appBar: AppBar( @@ -62,19 +44,16 @@ class _SourceFilterView extends StatelessWidget { ), actions: [ // Apply My Followed Sources Button - BlocBuilder( - builder: (context, appState) { + BlocBuilder( + builder: (context, filterState) { + final appState = context.watch().state; final followedSources = appState.userContentPreferences?.followedSources ?? []; - final sourcesFilterBloc = context.read(); + // Determine if the current selection matches the followed sources final isFollowedFilterActive = followedSources.isNotEmpty && - sourcesFilterBloc.state.finallySelectedSourceIds.length == - followedSources.length && - followedSources.every( - (source) => sourcesFilterBloc.state.finallySelectedSourceIds - .contains(source.id), - ); + filterState.selectedSources.length == followedSources.length && + filterState.selectedSources.containsAll(followedSources); return IconButton( icon: isFollowedFilterActive @@ -85,11 +64,6 @@ class _SourceFilterView extends StatelessWidget { : null, tooltip: l10n.headlinesFeedFilterApplyFollowedLabel, onPressed: () { - sourcesFilterBloc.add( - SourceFilterFollowedApplied( - followedSources: followedSources, - ), - ); if (followedSources.isEmpty) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() @@ -99,31 +73,35 @@ class _SourceFilterView extends StatelessWidget { duration: const Duration(seconds: 3), ), ); + } else { + // Toggle the followed items filter in the HeadlinesFilterBloc + context.read().add( + FollowedItemsFilterToggled( + isUsingFollowedItems: !isFollowedFilterActive, + ), + ); } }, ); }, ), + // Apply Filters Button (now just pops, as state is managed centrally) IconButton( icon: const Icon(Icons.check), tooltip: l10n.headlinesFeedFilterApplyButton, onPressed: () { - final selectedSources = state.displayableSources - .where((s) => state.finallySelectedSourceIds.contains(s.id)) - .toList(); - // Pop with a map containing all relevant filter state - Navigator.of(context).pop(selectedSources); + // The selections are already managed by HeadlinesFilterBloc. + // Just pop the page. + Navigator.of(context).pop(); }, ), ], ), - body: BlocBuilder( - builder: (context, state) { + body: BlocBuilder( + builder: (context, filterState) { final isLoadingMainList = - state.dataLoadingStatus == - SourceFilterDataLoadingStatus.loading && - state.allAvailableSources.isEmpty; + filterState.status == HeadlinesFilterStatus.loading; if (isLoadingMainList) { return LoadingStateWidget( @@ -132,27 +110,57 @@ class _SourceFilterView extends StatelessWidget { subheadline: l10n.sourceFilterLoadingSubheadline, ); } - if (state.dataLoadingStatus == - SourceFilterDataLoadingStatus.failure && - state.allAvailableSources.isEmpty) { + if (filterState.status == HeadlinesFilterStatus.failure && + filterState.allSources.isEmpty) { return FailureStateWidget( - exception: - state.error ?? + exception: filterState.error ?? const UnknownException( 'Failed to load source filter data.', ), onRetry: () { - context.read().add(const LoadSourceFilterData()); + context.read().add( + FilterDataLoaded( + initialSelectedTopics: filterState.selectedTopics.toList(), + initialSelectedSources: filterState.selectedSources.toList(), + initialSelectedCountries: filterState.selectedCountries.toList(), + isUsingFollowedItems: filterState.isUsingFollowedItems, + ), + ); }, ); } + // Filter sources based on selected countries and types from HeadlinesFilterBloc + final displayableSources = filterState.allSources.where((source) { + final matchesCountry = filterState.selectedCountries.isEmpty || + filterState.selectedCountries + .any((c) => c.isoCode == source.headquarters.isoCode); + // Assuming all source types are available and selected by default if none are explicitly selected + final matchesType = filterState.selectedSources.isEmpty || + filterState.selectedSources.contains(source); + return matchesCountry && matchesType; + }).toList(); + + if (displayableSources.isEmpty && + filterState.status != HeadlinesFilterStatus.loading) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.paddingLarge), + child: Text( + l10n.headlinesFeedFilterNoSourcesMatch, + style: textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ), + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCountryCapsules(context, state, l10n, textTheme), + _buildCountryCapsules(context, filterState, l10n, textTheme), const SizedBox(height: AppSpacing.md), - _buildSourceTypeCapsules(context, state, l10n, textTheme), + _buildSourceTypeCapsules(context, filterState, l10n, textTheme), const SizedBox(height: AppSpacing.md), Padding( padding: const EdgeInsets.symmetric( @@ -167,7 +175,13 @@ class _SourceFilterView extends StatelessWidget { ), const SizedBox(height: AppSpacing.sm), Expanded( - child: _buildSourcesList(context, state, l10n, textTheme), + child: _buildSourcesList( + context, + filterState, + l10n, + textTheme, + displayableSources, + ), ), ], ); @@ -178,7 +192,7 @@ class _SourceFilterView extends StatelessWidget { Widget _buildCountryCapsules( BuildContext context, - SourcesFilterState state, + HeadlinesFilterState filterState, AppLocalizations l10n, TextTheme textTheme, ) { @@ -198,7 +212,7 @@ class _SourceFilterView extends StatelessWidget { height: AppSpacing.xl + AppSpacing.md, child: ListView.separated( scrollDirection: Axis.horizontal, - itemCount: state.countriesWithActiveSources.length + 1, + itemCount: filterState.allCountries.length + 1, separatorBuilder: (context, index) => const SizedBox(width: AppSpacing.sm), itemBuilder: (context, index) { @@ -206,15 +220,21 @@ class _SourceFilterView extends StatelessWidget { return ChoiceChip( label: Text(l10n.headlinesFeedFilterAllLabel), labelStyle: textTheme.labelLarge, - selected: state.selectedCountryIsoCodes.isEmpty, + selected: filterState.selectedCountries.isEmpty, onSelected: (_) { - context.read().add( - const CountryCapsuleToggled(''), - ); + // Clear all country selections + for (final country in filterState.allCountries) { + context.read().add( + FilterCountryToggled( + country: country, + isSelected: false, + ), + ); + } }, ); } - final country = state.countriesWithActiveSources[index - 1]; + final country = filterState.allCountries[index - 1]; return ChoiceChip( avatar: country.flagUrl.isNotEmpty ? CircleAvatar( @@ -224,13 +244,14 @@ class _SourceFilterView extends StatelessWidget { : null, label: Text(country.name), labelStyle: textTheme.labelLarge, - selected: state.selectedCountryIsoCodes.contains( - country.isoCode, - ), - onSelected: (_) { - context.read().add( - CountryCapsuleToggled(country.isoCode), - ); + selected: filterState.selectedCountries.contains(country), + onSelected: (isSelected) { + context.read().add( + FilterCountryToggled( + country: country, + isSelected: isSelected, + ), + ); }, ); }, @@ -243,10 +264,22 @@ class _SourceFilterView extends StatelessWidget { Widget _buildSourceTypeCapsules( BuildContext context, - SourcesFilterState state, + HeadlinesFilterState filterState, AppLocalizations l10n, TextTheme textTheme, ) { + // For source types, we need to get all unique source types from all available sources + final allSourceTypes = filterState.allSources + .map((s) => s.sourceType) + .toSet() + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + + // Determine which source types are currently selected based on the selected sources + final selectedSourceTypes = filterState.selectedSources + .map((s) => s.sourceType) + .toSet(); + return Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium), child: Column( @@ -261,7 +294,7 @@ class _SourceFilterView extends StatelessWidget { height: AppSpacing.xl + AppSpacing.md, child: ListView.separated( scrollDirection: Axis.horizontal, - itemCount: state.availableSourceTypes.length + 1, + itemCount: allSourceTypes.length + 1, separatorBuilder: (context, index) => const SizedBox(width: AppSpacing.sm), itemBuilder: (context, index) { @@ -269,23 +302,36 @@ class _SourceFilterView extends StatelessWidget { return ChoiceChip( label: Text(l10n.headlinesFeedFilterAllLabel), labelStyle: textTheme.labelLarge, - selected: state.selectedSourceTypes.isEmpty, + selected: selectedSourceTypes.isEmpty, onSelected: (_) { - context.read().add( - const AllSourceTypesCapsuleToggled(), - ); + // Clear all source selections + for (final source in filterState.allSources) { + context.read().add( + FilterSourceToggled( + source: source, + isSelected: false, + ), + ); + } }, ); } - final sourceType = state.availableSourceTypes[index - 1]; + final sourceType = allSourceTypes[index - 1]; return ChoiceChip( label: Text(sourceType.name), labelStyle: textTheme.labelLarge, - selected: state.selectedSourceTypes.contains(sourceType), - onSelected: (_) { - context.read().add( - SourceTypeCapsuleToggled(sourceType), - ); + selected: selectedSourceTypes.contains(sourceType), + onSelected: (isSelected) { + // Toggle all sources of this type + for (final source in filterState.allSources + .where((s) => s.sourceType == sourceType)) { + context.read().add( + FilterSourceToggled( + source: source, + isSelected: isSelected, + ), + ); + } }, ); }, @@ -298,27 +344,34 @@ class _SourceFilterView extends StatelessWidget { Widget _buildSourcesList( BuildContext context, - SourcesFilterState state, + HeadlinesFilterState filterState, AppLocalizations l10n, TextTheme textTheme, + List displayableSources, ) { - if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.loading && - state.displayableSources.isEmpty) { + if (filterState.status == HeadlinesFilterStatus.loading && + displayableSources.isEmpty) { return const Center(child: CircularProgressIndicator()); } - if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.failure && - state.displayableSources.isEmpty) { + if (filterState.status == HeadlinesFilterStatus.failure && + displayableSources.isEmpty) { return FailureStateWidget( - exception: - state.error ?? + exception: filterState.error ?? const UnknownException('Failed to load displayable sources.'), onRetry: () { - context.read().add(const LoadSourceFilterData()); + context.read().add( + FilterDataLoaded( + initialSelectedTopics: filterState.selectedTopics.toList(), + initialSelectedSources: filterState.selectedSources.toList(), + initialSelectedCountries: filterState.selectedCountries.toList(), + isUsingFollowedItems: filterState.isUsingFollowedItems, + ), + ); }, ); } - if (state.displayableSources.isEmpty && - state.dataLoadingStatus != SourceFilterDataLoadingStatus.loading) { + if (displayableSources.isEmpty && + filterState.status != HeadlinesFilterStatus.loading) { return Center( child: Padding( padding: const EdgeInsets.all(AppSpacing.paddingLarge), @@ -335,17 +388,17 @@ class _SourceFilterView extends StatelessWidget { padding: const EdgeInsets.symmetric( vertical: AppSpacing.paddingSmall, ).copyWith(bottom: AppSpacing.xxl), - itemCount: state.displayableSources.length, + itemCount: displayableSources.length, itemBuilder: (context, index) { - final source = state.displayableSources[index]; + final source = displayableSources[index]; return CheckboxListTile( title: Text(source.name, style: textTheme.titleMedium), - value: state.finallySelectedSourceIds.contains(source.id), + value: filterState.selectedSources.contains(source), onChanged: (bool? value) { if (value != null) { - context.read().add( - SourceCheckboxToggled(source.id, value), - ); + context.read().add( + FilterSourceToggled(source: source, isSelected: value), + ); } }, controlAffinity: ListTileControlAffinity.leading, From fa60c982bf7b3a51c7814b1d9022da06af7fe4f0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 11:54:46 +0100 Subject: [PATCH 82/94] refactor(headlines-feed): migrate TopicFilterPage to use HeadlinesFilterBloc - Replace TopicsFilterBloc with HeadlinesFilterBloc for topic management - Convert TopicFilterPage from StatefulWidget to StatelessWidget - Remove local topic selection management, use centralized HeadlinesFilterBloc - Update UI to reflect changes in state from HeadlinesFilterBloc - Simplify follow filter logic, integrate with HeadlinesFilterBloc - Remove unnecessary scroll controller and pagination logic --- .../view/topic_filter_page.dart | 127 +++++++----------- 1 file changed, 46 insertions(+), 81 deletions(-) diff --git a/lib/headlines-feed/view/topic_filter_page.dart b/lib/headlines-feed/view/topic_filter_page.dart index b6fbeab3..0c1b544d 100644 --- a/lib/headlines-feed/view/topic_filter_page.dart +++ b/lib/headlines-feed/view/topic_filter_page.dart @@ -1,62 +1,21 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/topics_filter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_filter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; -import 'package:go_router/go_router.dart'; import 'package:ui_kit/ui_kit.dart'; /// {@template topic_filter_page} /// A page dedicated to selecting news topics for filtering headlines. +/// +/// This page now interacts with the centralized [HeadlinesFilterBloc] +/// to manage the list of available topics and the user's selections. /// {@endtemplate} -class TopicFilterPage extends StatefulWidget { +class TopicFilterPage extends StatelessWidget { /// {@macro topic_filter_page} const TopicFilterPage({super.key}); - @override - State createState() => _TopicFilterPageState(); -} - -class _TopicFilterPageState extends State { - final _scrollController = ScrollController(); - late final TopicsFilterBloc _topicsFilterBloc; - late Set _pageSelectedTopics; - - @override - void initState() { - super.initState(); - _scrollController.addListener(_onScroll); - _topicsFilterBloc = context.read() - ..add(TopicsFilterRequested()); - - WidgetsBinding.instance.addPostFrameCallback((_) { - // Initialize local selection from GoRouter extra parameter - final initialSelection = GoRouterState.of(context).extra as List?; - _pageSelectedTopics = Set.from(initialSelection ?? []); - }); - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - void _onScroll() { - if (_isBottom) { - _topicsFilterBloc.add(TopicsFilterLoadMoreRequested()); - } - } - - bool get _isBottom { - if (!_scrollController.hasClients) return false; - final maxScroll = _scrollController.position.maxScrollExtent; - final currentScroll = _scrollController.offset; - return currentScroll >= (maxScroll * 0.9); - } - @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; @@ -70,13 +29,16 @@ class _TopicFilterPageState extends State { ), actions: [ // Apply My Followed Topics Button - BlocBuilder( - builder: (context, appState) { + BlocBuilder( + builder: (context, filterState) { + final appState = context.watch().state; final followedTopics = appState.userContentPreferences?.followedTopics ?? []; + + // Determine if the current selection matches the followed topics final isFollowedFilterActive = followedTopics.isNotEmpty && - _pageSelectedTopics.length == followedTopics.length && - _pageSelectedTopics.containsAll(followedTopics); + filterState.selectedTopics.length == followedTopics.length && + filterState.selectedTopics.containsAll(followedTopics); return IconButton( icon: isFollowedFilterActive @@ -87,9 +49,6 @@ class _TopicFilterPageState extends State { : null, tooltip: l10n.headlinesFeedFilterApplyFollowedLabel, onPressed: () { - setState(() { - _pageSelectedTopics = Set.from(followedTopics); - }); if (followedTopics.isEmpty) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() @@ -99,27 +58,35 @@ class _TopicFilterPageState extends State { duration: const Duration(seconds: 3), ), ); + } else { + // Toggle the followed items filter in the HeadlinesFilterBloc + context.read().add( + FollowedItemsFilterToggled( + isUsingFollowedItems: !isFollowedFilterActive, + ), + ); } }, ); }, ), - // Apply Filters Button + // Apply Filters Button (now just pops, as state is managed centrally) IconButton( icon: const Icon(Icons.check), tooltip: l10n.headlinesFeedFilterApplyButton, onPressed: () { - context.pop(_pageSelectedTopics.toList()); + // The selections are already managed by HeadlinesFilterBloc. + // Just pop the page. + Navigator.of(context).pop(); }, ), ], ), - body: BlocBuilder( - builder: (context, state) { + body: BlocBuilder( + builder: (context, filterState) { // Determine overall loading status for the main list final isLoadingMainList = - state.status == TopicsFilterStatus.initial || - state.status == TopicsFilterStatus.loading; + filterState.status == HeadlinesFilterStatus.loading; if (isLoadingMainList) { return LoadingStateWidget( @@ -129,21 +96,27 @@ class _TopicFilterPageState extends State { ); } - if (state.status == TopicsFilterStatus.failure && - state.topics.isEmpty) { + if (filterState.status == HeadlinesFilterStatus.failure && + filterState.allTopics.isEmpty) { return Center( child: FailureStateWidget( - exception: - state.error ?? + exception: filterState.error ?? const UnknownException( 'An unknown error occurred while fetching topics.', ), - onRetry: () => _topicsFilterBloc.add(TopicsFilterRequested()), + onRetry: () => context.read().add( + FilterDataLoaded( + initialSelectedTopics: filterState.selectedTopics.toList(), + initialSelectedSources: filterState.selectedSources.toList(), + initialSelectedCountries: filterState.selectedCountries.toList(), + isUsingFollowedItems: filterState.isUsingFollowedItems, + ), + ), ), ); } - if (state.topics.isEmpty) { + if (filterState.allTopics.isEmpty) { return InitialStateWidget( icon: Icons.category_outlined, headline: l10n.topicFilterEmptyHeadline, @@ -152,27 +125,19 @@ class _TopicFilterPageState extends State { } return ListView.builder( - controller: _scrollController, - itemCount: state.hasMore - ? state.topics.length + 1 - : state.topics.length, + itemCount: filterState.allTopics.length, itemBuilder: (context, index) { - if (index >= state.topics.length) { - return const Center(child: CircularProgressIndicator()); - } - final topic = state.topics[index]; - final isSelected = _pageSelectedTopics.contains(topic); + final topic = filterState.allTopics[index]; + final isSelected = filterState.selectedTopics.contains(topic); return CheckboxListTile( title: Text(topic.name), value: isSelected, onChanged: (bool? value) { - setState(() { - if (value == true) { - _pageSelectedTopics.add(topic); - } else { - _pageSelectedTopics.remove(topic); - } - }); + if (value != null) { + context.read().add( + FilterTopicToggled(topic: topic, isSelected: value), + ); + } }, ); }, From fe33f62bbab994e5d82e82500902229ee317711e Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 11:56:01 +0100 Subject: [PATCH 83/94] refactor(headlines-feed): implement HeadlinesFilterBloc for state management - Replace local state management with HeadlinesFilterBloc - Remove temporary filter selection variables and logic - Add loading and failure states handling - Update UI to reflect new state management approach - Refactor filter tile building and selection logic --- .../view/headlines_filter_page.dart | 258 ++++++++---------- 1 file changed, 121 insertions(+), 137 deletions(-) diff --git a/lib/headlines-feed/view/headlines_filter_page.dart b/lib/headlines-feed/view/headlines_filter_page.dart index a6314dd8..4fd1e640 100644 --- a/lib/headlines-feed/view/headlines_filter_page.dart +++ b/lib/headlines-feed/view/headlines_filter_page.dart @@ -8,15 +8,13 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_feed_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_filter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/models/headline_filter.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:go_router/go_router.dart'; import 'package:ui_kit/ui_kit.dart'; -// Keys for passing data to/from SourceFilterPage -const String keySelectedSources = 'selectedSources'; - /// {@template headlines_filter_page} /// A full-screen dialog page for selecting headline filters. /// @@ -24,69 +22,48 @@ const String keySelectedSources = 'selectedSources'; /// sources, and event countries. Manages the temporary state of these /// selections before applying them to the [HeadlinesFeedBloc]. /// {@endtemplate} -class HeadlinesFilterPage extends StatefulWidget { +class HeadlinesFilterPage extends StatelessWidget { /// {@macro headlines_filter_page} const HeadlinesFilterPage({super.key}); @override - State createState() => _HeadlinesFilterPageState(); -} - -class _HeadlinesFilterPageState extends State { - /// Temporary state for filter selections within this modal flow. - /// These hold the selections made by the user *while* this filter page - /// and its sub-pages (Category, Source, Country) are open. - /// They are initialized from the main [HeadlinesFeedBloc]'s current filter - /// and are only applied back to the BLoC when the user taps 'Apply'. - late List _tempSelectedTopics; - late List _tempSelectedSources; - late List _tempSelectedEventCountries; - - /// Flag to indicate if the "Apply my followed items" filter is active. - bool _useFollowedFilters = false; - - @override - void initState() { - super.initState(); - final headlinesFeedState = BlocProvider.of( - context, - ).state; - - final currentFilter = headlinesFeedState.filter; - _tempSelectedTopics = List.from(currentFilter.topics ?? []); - _tempSelectedSources = List.from(currentFilter.sources ?? []); - _tempSelectedEventCountries = List.from(currentFilter.eventCountries ?? []); + Widget build(BuildContext context) { + // Access the HeadlinesFeedBloc to get the current filter state for initialization. + final headlinesFeedBloc = context.read(); + final currentFilter = headlinesFeedBloc.state.filter; - _useFollowedFilters = currentFilter.isFromFollowedItems; + return BlocProvider( + create: (context) => HeadlinesFilterBloc( + topicsRepository: context.read>(), + sourcesRepository: context.read>(), + countriesRepository: context.read>(), + appBloc: context.read(), + )..add( + FilterDataLoaded( + initialSelectedTopics: currentFilter.topics ?? [], + initialSelectedSources: currentFilter.sources ?? [], + initialSelectedCountries: currentFilter.eventCountries ?? [], + isUsingFollowedItems: currentFilter.isFromFollowedItems, + ), + ), + child: const _HeadlinesFilterView(), + ); } +} - /// Clears all temporary filter selections. - void _clearTemporaryFilters() { - setState(() { - _tempSelectedTopics = []; - _tempSelectedSources = []; - _tempSelectedEventCountries = []; - }); - } +class _HeadlinesFilterView extends StatelessWidget { + const _HeadlinesFilterView(); /// Builds a [ListTile] representing a filter criterion (e.g., Categories). /// /// Displays the criterion [title], the number of currently selected items /// ([selectedCount]), and navigates to the corresponding selection page /// specified by [routeName] when tapped. - /// - /// Uses [currentSelection] to pass the current temporary selection state - /// (e.g., `_tempSelectedSources`) to the corresponding criterion selection page - /// (e.g., `SourceFilterPage`) via the `extra` parameter of `context.pushNamed`. - /// Updates the temporary state via the [onResult] callback when the - /// criterion page pops with a result (the user tapped 'Apply' on that page). - Widget _buildFilterTile({ + Widget _buildFilterTile({ required BuildContext context, required String title, required int selectedCount, required String routeName, - required List currentSelectionData, - required void Function(List)? onResult, bool enabled = true, }) { final l10n = AppLocalizationsX(context).l10n; @@ -103,14 +80,10 @@ class _HeadlinesFilterPageState extends State { trailing: const Icon(Icons.chevron_right), enabled: enabled, onTap: enabled - ? () async { - final result = await context.pushNamed>( - routeName, - extra: currentSelectionData, - ); - if (result != null && onResult != null) { - onResult(result); - } + ? () { + // Navigate to the child filter page. The child page will read + // the current selections from HeadlinesFilterBloc directly. + context.pushNamed(routeName); } : null, ); @@ -121,10 +94,6 @@ class _HeadlinesFilterPageState extends State { final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); - // Determine if the "Apply my followed items" feature is active. - // This will disable the individual filter tiles. - final isFollowedFilterActive = _useFollowedFilters; - return Scaffold( appBar: AppBar( leading: IconButton( @@ -139,22 +108,15 @@ class _HeadlinesFilterPageState extends State { icon: const Icon(Icons.refresh), tooltip: l10n.headlinesFeedFilterResetButton, onPressed: () { - context.read().add( - HeadlinesFeedFiltersCleared( - adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), - ), - ); - // Also reset local state for the "Apply my followed items" - setState(() { - _useFollowedFilters = false; - _clearTemporaryFilters(); - }); - context.pop(); + context.read().add( + const FilterSelectionsCleared(), + ); }, ), // Apply My Followed Items Button - BlocBuilder( - builder: (context, appState) { + BlocBuilder( + builder: (context, filterState) { + final appState = context.watch().state; final followedTopics = appState.userContentPreferences?.followedTopics ?? []; final followedSources = @@ -167,24 +129,21 @@ class _HeadlinesFilterPageState extends State { followedCountries.isNotEmpty; return IconButton( - icon: _useFollowedFilters + icon: filterState.isUsingFollowedItems ? const Icon(Icons.favorite) : const Icon(Icons.favorite_border), - color: _useFollowedFilters ? theme.colorScheme.primary : null, + color: filterState.isUsingFollowedItems + ? theme.colorScheme.primary + : null, tooltip: l10n.headlinesFeedFilterApplyFollowedLabel, onPressed: hasFollowedItems ? () { - setState(() { - _useFollowedFilters = !_useFollowedFilters; - if (_useFollowedFilters) { - _tempSelectedTopics = List.from(followedTopics); - _tempSelectedSources = List.from(followedSources); - _tempSelectedEventCountries = - List.from(followedCountries); - } else { - _clearTemporaryFilters(); - } - }); + context.read().add( + FollowedItemsFilterToggled( + isUsingFollowedItems: + !filterState.isUsingFollowedItems, + ), + ); } : () { ScaffoldMessenger.of(context) @@ -206,67 +165,92 @@ class _HeadlinesFilterPageState extends State { icon: const Icon(Icons.check), tooltip: l10n.headlinesFeedFilterApplyButton, onPressed: () { + final filterState = context.read().state; final newFilter = HeadlineFilter( - topics: _tempSelectedTopics.isNotEmpty - ? _tempSelectedTopics + topics: filterState.selectedTopics.isNotEmpty + ? filterState.selectedTopics.toList() : null, - sources: _tempSelectedSources.isNotEmpty - ? _tempSelectedSources + sources: filterState.selectedSources.isNotEmpty + ? filterState.selectedSources.toList() : null, - eventCountries: _tempSelectedEventCountries.isNotEmpty - ? _tempSelectedEventCountries + eventCountries: filterState.selectedCountries.isNotEmpty + ? filterState.selectedCountries.toList() : null, - isFromFollowedItems: _useFollowedFilters, + isFromFollowedItems: filterState.isUsingFollowedItems, ); context.read().add( - HeadlinesFeedFiltersApplied( - filter: newFilter, - adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), - ), - ); + HeadlinesFeedFiltersApplied( + filter: newFilter, + adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), + ), + ); context.pop(); }, ), ], ), - body: ListView( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), - children: [ - const Divider(), - _buildFilterTile( - context: context, - title: l10n.headlinesFeedFilterTopicLabel, - enabled: !isFollowedFilterActive, - selectedCount: _tempSelectedTopics.length, - routeName: Routes.feedFilterTopicsName, - currentSelectionData: _tempSelectedTopics, - onResult: (result) { - setState(() => _tempSelectedTopics = result); - }, - ), - _buildFilterTile( - context: context, - title: l10n.headlinesFeedFilterSourceLabel, - enabled: !isFollowedFilterActive, - selectedCount: _tempSelectedSources.length, - routeName: Routes.feedFilterSourcesName, - currentSelectionData: _tempSelectedSources, - onResult: (result) { - setState(() => _tempSelectedSources = result); - }, - ), - _buildFilterTile( - context: context, - title: l10n.headlinesFeedFilterEventCountryLabel, - enabled: !isFollowedFilterActive, - selectedCount: _tempSelectedEventCountries.length, - routeName: Routes.feedFilterEventCountriesName, - currentSelectionData: _tempSelectedEventCountries, - onResult: (result) { - setState(() => _tempSelectedEventCountries = result); - }, - ), - ], + body: BlocBuilder( + builder: (context, filterState) { + // Determine if the "Apply my followed items" feature is active. + // This will disable the individual filter tiles. + final isFollowedFilterActive = filterState.isUsingFollowedItems; + + if (filterState.status == HeadlinesFilterStatus.loading) { + return LoadingStateWidget( + icon: Icons.filter_list, + headline: l10n.headlinesFeedFilterLoadingHeadline, + subheadline: l10n.pleaseWait, + ); + } + + if (filterState.status == HeadlinesFilterStatus.failure) { + return FailureStateWidget( + exception: filterState.error ?? + const UnknownException('Failed to load filter data.'), + onRetry: () { + final headlinesFeedBloc = context.read(); + final currentFilter = headlinesFeedBloc.state.filter; + context.read().add( + FilterDataLoaded( + initialSelectedTopics: currentFilter.topics ?? [], + initialSelectedSources: currentFilter.sources ?? [], + initialSelectedCountries: + currentFilter.eventCountries ?? [], + isUsingFollowedItems: currentFilter.isFromFollowedItems, + ), + ); + }, + ); + } + + return ListView( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + children: [ + const Divider(), + _buildFilterTile( + context: context, + title: l10n.headlinesFeedFilterTopicLabel, + enabled: !isFollowedFilterActive, + selectedCount: filterState.selectedTopics.length, + routeName: Routes.feedFilterTopicsName, + ), + _buildFilterTile( + context: context, + title: l10n.headlinesFeedFilterSourceLabel, + enabled: !isFollowedFilterActive, + selectedCount: filterState.selectedSources.length, + routeName: Routes.feedFilterSourcesName, + ), + _buildFilterTile( + context: context, + title: l10n.headlinesFeedFilterEventCountryLabel, + enabled: !isFollowedFilterActive, + selectedCount: filterState.selectedCountries.length, + routeName: Routes.feedFilterEventCountriesName, + ), + ], + ); + }, ), ); } From 7b5e44bae19d00df76adaad651b77725962b8068 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 11:56:36 +0100 Subject: [PATCH 84/94] feat(l10n): add loading headline for filters page in headlines feed - Add new localization strings for both Arabic and English - Introduce "headlinesFeedFilterLoadingHeadline" for loading state on headlines filter page - Update corresponding description for both language files --- lib/l10n/app_localizations.dart | 6 ++++++ lib/l10n/app_localizations_ar.dart | 3 +++ lib/l10n/app_localizations_en.dart | 3 +++ lib/l10n/arb/app_ar.arb | 4 ++++ lib/l10n/arb/app_en.arb | 4 ++++ 5 files changed, 20 insertions(+) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ec3d8832..46a8751a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1687,6 +1687,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Retry'** String get retryButtonText; + + /// Headline for the loading state when fetching filter options on the headlines filter page. + /// + /// In en, this message translates to: + /// **'Loading Filters'** + String get headlinesFeedFilterLoadingHeadline; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 53f1510b..1ba4048e 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -880,4 +880,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get retryButtonText => 'إعادة المحاولة'; + + @override + String get headlinesFeedFilterLoadingHeadline => 'جاري تحميل الفلاتر'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d4f5499f..410fdc79 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -882,4 +882,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get retryButtonText => 'Retry'; + + @override + String get headlinesFeedFilterLoadingHeadline => 'Loading Filters'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index bfe2bdcc..c91422c7 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1148,5 +1148,9 @@ "retryButtonText": "إعادة المحاولة", "@retryButtonText": { "description": "Text for a button that allows the user to retry a failed operation." + }, + "headlinesFeedFilterLoadingHeadline": "جاري تحميل الفلاتر", + "@headlinesFeedFilterLoadingHeadline": { + "description": "Headline for the loading state when fetching filter options on the headlines filter page." } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index f0ace05a..6e83e85f 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1148,5 +1148,9 @@ "retryButtonText": "Retry", "@retryButtonText": { "description": "Text for a button that allows the user to retry a failed operation." + }, + "headlinesFeedFilterLoadingHeadline": "Loading Filters", + "@headlinesFeedFilterLoadingHeadline": { + "description": "Headline for the loading state when fetching filter options on the headlines filter page." } } \ No newline at end of file From 7a59f9dc69712b531822fedd6f1f6c4f850d0ff9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 11:56:54 +0100 Subject: [PATCH 85/94] refactor(router): remove unused BlocProvider wrappers - Remove BlocProvider from TopicFilterPage and SourceFilterPage - Simplify CountryFilterPage by removing unnecessary BlocProvider - Improve code readability and reduce complexity in feed filter routes --- lib/router/router.dart | 56 +++--------------------------------------- 1 file changed, 4 insertions(+), 52 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index 82bc8f68..664e4203 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -470,70 +470,22 @@ GoRouter createRouter({ GoRoute( path: Routes.feedFilterTopics, name: Routes.feedFilterTopicsName, - // Wrap with BlocProviderUserContentPreferencesRepository - builder: (context, state) => BlocProvider( - create: (context) => TopicsFilterBloc( - topicsRepository: context - .read>(), - userContentPreferencesRepository: context - .read>(), - appBloc: context.read(), - ), - child: const TopicFilterPage(), - ), + builder: (context, state) => const TopicFilterPage(), ), // Sub-route for source selection GoRoute( path: Routes.feedFilterSources, name: Routes.feedFilterSourcesName, - // Wrap with BlocProvider - builder: (context, state) => BlocProvider( - create: (context) => SourcesFilterBloc( - sourcesRepository: context - .read>(), - countriesRepository: context - .read>(), - userContentPreferencesRepository: context - .read>(), - appBloc: context.read(), - ), - // Pass initialSelectedSources from state.extra - child: Builder( - builder: (context) { - final initialSources = - state.extra as List? ?? const []; - - return SourceFilterPage( - initialSelectedSources: initialSources, - ); - }, - ), - ), + builder: (context, state) => const SourceFilterPage(), ), GoRoute( path: Routes.feedFilterEventCountries, name: Routes.feedFilterEventCountriesName, pageBuilder: (context, state) { final l10n = context.l10n; - final initialSelection = - state.extra as List?; return MaterialPage( - child: BlocProvider( - create: (context) => CountriesFilterBloc( - countriesRepository: context - .read>(), - userContentPreferencesRepository: context - .read< - DataRepository - >(), - appBloc: context.read(), - ), - child: CountryFilterPage( - title: - l10n.headlinesFeedFilterEventCountryLabel, - filter: CountryFilterUsage.hasActiveHeadlines, - key: ValueKey(initialSelection.hashCode), - ), + child: CountryFilterPage( + title: l10n.headlinesFeedFilterEventCountryLabel, ), ); }, From e95e5b28ee70902e90d2435bb8351b30ead7f105 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 12:18:43 +0100 Subject: [PATCH 86/94] refactor(headlines-search): improve HeadlinesSearchBloc documentation and code quality - Add comprehensive documentation for HeadlinesSearchBloc using dartdoc - Optimize code for fetching user settings from AppBloc - Improve method documentation for search-related functions - Make code more concise and easier to understand --- .../bloc/headlines_search_bloc.dart | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/headlines-search/bloc/headlines_search_bloc.dart b/lib/headlines-search/bloc/headlines_search_bloc.dart index 365827a8..1e4c1192 100644 --- a/lib/headlines-search/bloc/headlines_search_bloc.dart +++ b/lib/headlines-search/bloc/headlines_search_bloc.dart @@ -13,8 +13,17 @@ import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/ part 'headlines_search_event.dart'; part 'headlines_search_state.dart'; +/// {@template headlines_search_bloc} +/// A BLoC that manages the state for the headlines search feature. +/// +/// This BLoC is responsible for fetching search results based on a query +/// and selected content type, and for injecting ad placeholders into the +/// headline results. It consumes global application state from [AppBloc] +/// for user settings and remote configuration. +/// {@endtemplate} class HeadlinesSearchBloc extends Bloc { + /// {@macro headlines_search_bloc} HeadlinesSearchBloc({ required DataRepository headlinesRepository, required DataRepository topicRepository, @@ -47,6 +56,10 @@ class HeadlinesSearchBloc final InlineAdCacheService _inlineAdCacheService; static const _limit = 10; + /// Handles changes to the selected model type for search. + /// + /// If there's an active search term, it re-triggers the search with the + /// new model type. Future _onHeadlinesSearchModelTypeChanged( HeadlinesSearchModelTypeChanged event, Emitter emit, @@ -64,6 +77,11 @@ class HeadlinesSearchBloc emit(HeadlinesSearchInitial(selectedModelType: event.newModelType)); } + /// Handles requests to fetch search results. + /// + /// This method performs the actual search operation based on the search term + /// and selected model type. It also handles pagination and injects ad + /// placeholders into headline results. Future _onSearchFetchRequested( HeadlinesSearchFetchRequested event, Emitter emit, @@ -125,7 +143,7 @@ class HeadlinesSearchBloc user: currentUser, adConfig: appConfig.adConfig, imageStyle: - _appBloc.state.settings.feedPreferences.headlineImageStyle, + _appBloc.state.headlineImageStyle, // Use AppBloc getter adThemeStyle: event.adThemeStyle, // Calculate the count of actual content items (headlines) already in the // feed. This is crucial for the FeedDecoratorService to correctly apply @@ -158,7 +176,6 @@ class HeadlinesSearchBloc cursor: response.cursor, ), ); - // Added break case ContentType.source: response = await _sourceRepository.readAll( filter: {'q': searchTerm}, @@ -245,7 +262,7 @@ class HeadlinesSearchBloc user: currentUser, adConfig: appConfig.adConfig, imageStyle: - _appBloc.state.settings.feedPreferences.headlineImageStyle, + _appBloc.state.headlineImageStyle, // Use AppBloc getter adThemeStyle: event.adThemeStyle, ); case ContentType.topic: From 5edcb6eadefc4a34a6fa8957cdddeaa52dc1bdd3 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 17 Sep 2025 12:19:02 +0100 Subject: [PATCH 87/94] refactor(headlines-search): improve HeadlinesSearchPage implementation - Convert HeadlinesSearchPage from StatelessWidget to StatefulWidget - Remove unnecessary _HeadlinesSearchView widget - Simplify state management and UI building logic - Enhance code readability and maintainability --- .../view/headlines_search_page.dart | 408 ++++++++---------- 1 file changed, 188 insertions(+), 220 deletions(-) diff --git a/lib/headlines-search/view/headlines_search_page.dart b/lib/headlines-search/view/headlines_search_page.dart index 57ac79ee..52c1ad54 100644 --- a/lib/headlines-search/view/headlines_search_page.dart +++ b/lib/headlines-search/view/headlines_search_page.dart @@ -23,35 +23,27 @@ import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/f import 'package:go_router/go_router.dart'; import 'package:ui_kit/ui_kit.dart'; -/// Page widget responsible for providing the BLoC for the headlines search feature. -class HeadlinesSearchPage extends StatelessWidget { +/// {@template headlines_search_page} +/// The main page for the headlines search feature. +/// +/// This widget is responsible for building the UI for the headlines search +/// page, including the search bar, model type selection, and displaying +/// search results. It consumes state from [HeadlinesSearchBloc] and +/// dispatches events for search operations. It also leverages [AppBloc] +/// for global settings like `headlineImageStyle` and `adConfig`. +/// {@endtemplate} +class HeadlinesSearchPage extends StatefulWidget { + /// {@macro headlines_search_page} const HeadlinesSearchPage({super.key}); - /// Defines the route for this page. - static Route route() { - return MaterialPageRoute(builder: (_) => const HeadlinesSearchPage()); - } - - @override - Widget build(BuildContext context) { - return const _HeadlinesSearchView(); - } -} - -/// Private View widget that builds the UI for the headlines search page. -/// It listens to the HeadlinesSearchBloc state and displays the appropriate UI. -class _HeadlinesSearchView extends StatefulWidget { - const _HeadlinesSearchView(); - @override - State<_HeadlinesSearchView> createState() => _HeadlinesSearchViewState(); + State createState() => _HeadlinesSearchPageState(); } -class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { +class _HeadlinesSearchPageState extends State { final _scrollController = ScrollController(); final _textController = TextEditingController(); bool _showClearButton = false; - ContentType _selectedModelType = ContentType.headline; @override void initState() { @@ -62,18 +54,14 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { _showClearButton = _textController.text.isNotEmpty; }); }); - final searchableTypes = [ - ContentType.headline, - ContentType.topic, - ContentType.source, - ContentType.country, - ]; - if (!searchableTypes.contains(_selectedModelType)) { - _selectedModelType = ContentType.headline; - } + // Initialize the selected model type from the BLoC's initial state. + // This ensures consistency if the BLoC was already initialized with a + // specific type (e.g., after a hot restart). + final initialModelType = + context.read().state.selectedModelType; context.read().add( - HeadlinesSearchModelTypeChanged(_selectedModelType), - ); + HeadlinesSearchModelTypeChanged(initialModelType), + ); } @override @@ -89,11 +77,11 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { final state = context.read().state; if (_isBottom && state is HeadlinesSearchSuccess && state.hasMore) { context.read().add( - HeadlinesSearchFetchRequested( - searchTerm: state.lastSearchTerm, - adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), - ), - ); + HeadlinesSearchFetchRequested( + searchTerm: state.lastSearchTerm, + adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), + ), + ); } } @@ -106,11 +94,11 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { void _performSearch() { context.read().add( - HeadlinesSearchFetchRequested( - searchTerm: _textController.text, - adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), - ), - ); + HeadlinesSearchFetchRequested( + searchTerm: _textController.text, + adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), + ), + ); } @override @@ -128,69 +116,62 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ContentType.country, ]; - if (!availableSearchModelTypes.contains(_selectedModelType)) { - _selectedModelType = ContentType.headline; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - context.read().add( - HeadlinesSearchModelTypeChanged(_selectedModelType), - ); - } - }); - } - return Scaffold( appBar: AppBar( titleSpacing: AppSpacing.paddingSmall, - // backgroundColor: appBarTheme.backgroundColor ?? colorScheme.surface, elevation: appBarTheme.elevation ?? 0, title: Row( children: [ - SizedBox( - width: 150, - child: DropdownButtonFormField( - value: _selectedModelType, - decoration: const InputDecoration( - border: InputBorder.none, - contentPadding: EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, + // BlocBuilder to react to changes in selectedModelType from HeadlinesSearchBloc + BlocBuilder( + buildWhen: (previous, current) => + previous.selectedModelType != current.selectedModelType, + builder: (context, state) { + // Ensure the selected model type is always one of the available types. + // If not, default to headline. + final currentSelectedModelType = + availableSearchModelTypes.contains(state.selectedModelType) + ? state.selectedModelType + : ContentType.headline; + + return SizedBox( + width: 150, + child: DropdownButtonFormField( + value: currentSelectedModelType, + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + isDense: true, + ), + style: textTheme.titleMedium?.copyWith( + color: appBarTheme.titleTextStyle?.color ?? + colorScheme.onSurface, + ), + dropdownColor: colorScheme.surfaceContainerHighest, + icon: Icon( + Icons.arrow_drop_down_rounded, + color: appBarTheme.iconTheme?.color ?? + colorScheme.onSurfaceVariant, + ), + items: availableSearchModelTypes.map((ContentType type) { + return DropdownMenuItem( + value: type, + child: Text(type.displayName(context)), + ); + }).toList(), + onChanged: (ContentType? newValue) { + if (newValue != null) { + context.read().add( + HeadlinesSearchModelTypeChanged(newValue), + ); + } + }, ), - isDense: true, - ), - style: textTheme.titleMedium?.copyWith( - color: - appBarTheme.titleTextStyle?.color ?? - colorScheme.onSurface, - ), - dropdownColor: colorScheme.surfaceContainerHighest, - icon: Icon( - Icons.arrow_drop_down_rounded, - color: - appBarTheme.iconTheme?.color ?? - colorScheme.onSurfaceVariant, - ), - // TODO(fulleni): Use the new localization extension here. - items: availableSearchModelTypes.map((ContentType type) { - return DropdownMenuItem( - value: type, - child: Text(type.displayName(context)), - ); - }).toList(), - onChanged: (ContentType? newValue) { - if (newValue != null) { - setState(() { - _selectedModelType = newValue; - }); - context.read().add( - HeadlinesSearchModelTypeChanged(newValue), - ); - // Optionally trigger search or clear text when type changes - // _textController.clear(); - // _performSearch(); - } - }, - ), + ); + }, ), const SizedBox(width: AppSpacing.sm), Expanded( @@ -200,14 +181,12 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { decoration: InputDecoration( hintText: l10n.searchHintTextGeneric, hintStyle: textTheme.bodyMedium?.copyWith( - color: - (appBarTheme.titleTextStyle?.color ?? - colorScheme.onSurface) - .withOpacity(0.6), + color: (appBarTheme.titleTextStyle?.color ?? + colorScheme.onSurface) + .withOpacity(0.6), ), border: InputBorder.none, filled: false, - // fillColor: colorScheme.surface.withAlpha(26), contentPadding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.sm, @@ -216,8 +195,7 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ? IconButton( icon: Icon( Icons.clear_rounded, - color: - appBarTheme.iconTheme?.color ?? + color: appBarTheme.iconTheme?.color ?? colorScheme.onSurfaceVariant, ), onPressed: _textController.clear, @@ -234,7 +212,6 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { icon: const Icon(Icons.search_outlined), tooltip: l10n.headlinesSearchActionTooltip, onPressed: _performSearch, - // color: appBarTheme.actionsIconTheme?.color, ), const SizedBox(width: AppSpacing.xs), ], @@ -243,18 +220,17 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { builder: (context, state) { return switch (state) { HeadlinesSearchInitial() => InitialStateWidget( - icon: Icons.search_outlined, - headline: l10n.searchPageInitialHeadline, - subheadline: l10n.searchPageInitialSubheadline, - ), + icon: Icons.search_outlined, + headline: l10n.searchPageInitialHeadline, + subheadline: l10n.searchPageInitialSubheadline, + ), HeadlinesSearchLoading() => LoadingStateWidget( - // Use LoadingStateWidget - icon: Icons.search_outlined, - headline: l10n.headlinesFeedLoadingHeadline, - subheadline: l10n.searchingFor( - state.selectedModelType.displayName(context).toLowerCase(), + icon: Icons.search_outlined, + headline: l10n.headlinesFeedLoadingHeadline, + subheadline: l10n.searchingFor( + state.selectedModelType.displayName(context).toLowerCase(), + ), ), - ), HeadlinesSearchSuccess( items: final items, hasMore: final hasMore, @@ -266,113 +242,105 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ? FailureStateWidget( exception: UnknownException(errorMessage), onRetry: () => context.read().add( - HeadlinesSearchFetchRequested( - searchTerm: lastSearchTerm, - adThemeStyle: AdThemeStyle.fromTheme(theme), - ), - ), + HeadlinesSearchFetchRequested( + searchTerm: lastSearchTerm, + adThemeStyle: AdThemeStyle.fromTheme(theme), + ), + ), ) : items.isEmpty - ? InitialStateWidget( - // Use InitialStateWidget for no results as it's not a failure - icon: Icons.search_off_outlined, - headline: l10n.headlinesSearchNoResultsHeadline, - subheadline: - 'For "$lastSearchTerm" in ${resultsModelType.displayName(context).toLowerCase()}.\n${l10n.headlinesSearchNoResultsSubheadline}', - ) - : ListView.separated( - controller: _scrollController, - padding: const EdgeInsets.symmetric( - // Consistent padding - horizontal: AppSpacing.paddingMedium, - vertical: AppSpacing.paddingSmall, - ).copyWith(bottom: AppSpacing.xxl), - itemCount: hasMore ? items.length + 1 : items.length, - separatorBuilder: (context, index) => - const SizedBox(height: AppSpacing.sm), - itemBuilder: (context, index) { - if (index >= items.length) { - return const Padding( - padding: EdgeInsets.symmetric( - vertical: AppSpacing.lg, - ), - child: Center(child: CircularProgressIndicator()), - ); - } - final feedItem = items[index]; + ? InitialStateWidget( + icon: Icons.search_off_outlined, + headline: l10n.headlinesSearchNoResultsHeadline, + subheadline: + 'For "$lastSearchTerm" in ${resultsModelType.displayName(context).toLowerCase()}.\n${l10n.headlinesSearchNoResultsSubheadline}', + ) + : ListView.separated( + controller: _scrollController, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.paddingSmall, + ).copyWith(bottom: AppSpacing.xxl), + itemCount: hasMore ? items.length + 1 : items.length, + separatorBuilder: (context, index) => + const SizedBox(height: AppSpacing.sm), + itemBuilder: (context, index) { + if (index >= items.length) { + return const Padding( + padding: EdgeInsets.symmetric( + vertical: AppSpacing.lg, + ), + child: Center(child: CircularProgressIndicator()), + ); + } + final feedItem = items[index]; - if (feedItem is Headline) { - final imageStyle = context - .watch() - .state - .settings - .feedPreferences - .headlineImageStyle; - Widget tile; - Future onHeadlineTap() async { - await context - .read() - .onPotentialAdTrigger(); + if (feedItem is Headline) { + final imageStyle = context + .watch() + .state + .headlineImageStyle; // Use AppBloc getter + Widget tile; + Future onHeadlineTap() async { + await context + .read() + .onPotentialAdTrigger(); - // Check if the widget is still in the tree before navigating. - if (!context.mounted) return; + if (!context.mounted) return; - await context.pushNamed( - Routes.searchArticleDetailsName, - pathParameters: {'id': feedItem.id}, - extra: feedItem, - ); - } + await context.pushNamed( + Routes.searchArticleDetailsName, + pathParameters: {'id': feedItem.id}, + extra: feedItem, + ); + } - switch (imageStyle) { - case HeadlineImageStyle.hidden: - tile = HeadlineTileTextOnly( - headline: feedItem, - onHeadlineTap: onHeadlineTap, - ); - case HeadlineImageStyle.smallThumbnail: - tile = HeadlineTileImageStart( - headline: feedItem, - onHeadlineTap: onHeadlineTap, - ); - case HeadlineImageStyle.largeThumbnail: - tile = HeadlineTileImageTop( - headline: feedItem, - onHeadlineTap: onHeadlineTap, - ); - } - return tile; - } else if (feedItem is Topic) { - return TopicItemWidget(topic: feedItem); - } else if (feedItem is Source) { - return SourceItemWidget(source: feedItem); - } else if (feedItem is Country) { - return CountryItemWidget(country: feedItem); - } else if (feedItem is AdPlaceholder) { - // Access the AppBloc to get the remoteConfig for ads. - final adConfig = context - .read() - .state - .remoteConfig - ?.adConfig; + switch (imageStyle) { + case HeadlineImageStyle.hidden: + tile = HeadlineTileTextOnly( + headline: feedItem, + onHeadlineTap: onHeadlineTap, + ); + case HeadlineImageStyle.smallThumbnail: + tile = HeadlineTileImageStart( + headline: feedItem, + onHeadlineTap: onHeadlineTap, + ); + case HeadlineImageStyle.largeThumbnail: + tile = HeadlineTileImageTop( + headline: feedItem, + onHeadlineTap: onHeadlineTap, + ); + } + return tile; + } else if (feedItem is Topic) { + return TopicItemWidget(topic: feedItem); + } else if (feedItem is Source) { + return SourceItemWidget(source: feedItem); + } else if (feedItem is Country) { + return CountryItemWidget(country: feedItem); + } else if (feedItem is AdPlaceholder) { + final adConfig = context + .watch() + .state + .remoteConfig + ?.adConfig; // Use AppBloc getter - // Ensure adConfig is not null before building the AdLoaderWidget. - if (adConfig == null) { - // Return an empty widget or a placeholder if adConfig is not available. - return const SizedBox.shrink(); - } + if (adConfig == null) { + return const SizedBox.shrink(); + } - return FeedAdLoaderWidget( - adPlaceholder: feedItem, - adThemeStyle: AdThemeStyle.fromTheme( - Theme.of(context), - ), - adConfig: adConfig, - ); - } - return const SizedBox.shrink(); - }, - ), + return FeedAdLoaderWidget( + adPlaceholder: feedItem, + adThemeStyle: AdThemeStyle.fromTheme( + Theme.of(context), + ), + adConfig: adConfig, + ); + } + return const SizedBox.shrink(); + }, + ), HeadlinesSearchFailure( errorMessage: final errorMessage, lastSearchTerm: final lastSearchTerm, @@ -383,11 +351,11 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { 'Failed to search "$lastSearchTerm" in ${failedModelType.displayName(context).toLowerCase()}:\n$errorMessage', ), onRetry: () => context.read().add( - HeadlinesSearchFetchRequested( - searchTerm: lastSearchTerm, - adThemeStyle: AdThemeStyle.fromTheme(theme), - ), - ), + HeadlinesSearchFetchRequested( + searchTerm: lastSearchTerm, + adThemeStyle: AdThemeStyle.fromTheme(theme), + ), + ), ), _ => const SizedBox.shrink(), }; From 56a41d02104107473693112d41934a9879262b43 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 18 Sep 2025 06:18:11 +0100 Subject: [PATCH 88/94] refactor(router): remove redundant AccountBloc provider - Remove BlocProvider.value(value: accountBloc) from multiple places in the router - This change removes unnecessary duplication of the AccountBloc provider - The AccountBloc should be provided higher up in the widget tree (e.g., in AppShell or App) - This refactor simplifies the router configuration and improves performance --- lib/router/router.dart | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index 664e4203..f4f3e6ce 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -26,10 +26,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/entity_details/v import 'package:flutter_news_app_mobile_client_full_source_code/headline-details/bloc/headline_details_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headline-details/bloc/similar_headlines_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headline-details/view/headline_details_page.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/countries_filter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_feed_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/sources_filter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/topics_filter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/view/country_filter_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/view/headlines_feed_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/view/headlines_filter_page.dart'; @@ -276,7 +273,6 @@ GoRouter createRouter({ final adThemeStyle = AdThemeStyle.fromTheme(Theme.of(context)); return MultiBlocProvider( providers: [ - BlocProvider.value(value: accountBloc), BlocProvider( create: (context) => EntityDetailsBloc( @@ -333,15 +329,8 @@ GoRouter createRouter({ final headlineFromExtra = state.extra as Headline?; final headlineIdFromPath = state.pathParameters['id']; - // Ensure accountBloc is available if needed by HeadlineDetailsPage - // or its descendants for actions like saving. - // If AccountBloc is already provided higher up (e.g., in AppShell or App), - // this specific BlocProvider.value might not be strictly necessary here, - // but it's safer to ensure it's available for this top-level route. - // We are using the `accountBloc` instance created at the top of `createRouter`. return MultiBlocProvider( providers: [ - BlocProvider.value(value: accountBloc), BlocProvider( create: (context) => HeadlineDetailsBloc( headlinesRepository: context.read>(), @@ -366,14 +355,11 @@ GoRouter createRouter({ // Return the shell widget which contains the AdaptiveScaffold return MultiBlocProvider( providers: [ - BlocProvider.value(value: accountBloc), BlocProvider( create: (context) { return HeadlinesFeedBloc( headlinesRepository: context .read>(), - userContentPreferencesRepository: context - .read>(), feedDecoratorService: feedDecoratorService, appBloc: context.read(), inlineAdCacheService: inlineAdCacheService, @@ -417,7 +403,6 @@ GoRouter createRouter({ return MultiBlocProvider( providers: [ - BlocProvider.value(value: accountBloc), BlocProvider( create: (context) => HeadlineDetailsBloc( headlinesRepository: context @@ -513,7 +498,6 @@ GoRouter createRouter({ final headlineIdFromPath = state.pathParameters['id']; return MultiBlocProvider( providers: [ - BlocProvider.value(value: accountBloc), BlocProvider( create: (context) => HeadlineDetailsBloc( headlinesRepository: context @@ -695,7 +679,6 @@ GoRouter createRouter({ final headlineIdFromPath = state.pathParameters['id']; return MultiBlocProvider( providers: [ - BlocProvider.value(value: accountBloc), BlocProvider( create: (context) => HeadlineDetailsBloc( headlinesRepository: context From 1777c90d1a2816cf7c04bf8f4fe306d7a1b8fcd9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 18 Sep 2025 06:19:56 +0100 Subject: [PATCH 89/94] style: format misc --- .../countries/add_country_to_follow_page.dart | 28 +- .../followed_countries_list_page.dart | 21 +- .../sources/add_source_to_follow_page.dart | 28 +- .../sources/followed_sources_list_page.dart | 21 +- .../topics/add_topic_to_follow_page.dart | 28 +- .../topics/followed_topics_list_page.dart | 21 +- lib/account/view/saved_headlines_page.dart | 27 +- lib/ads/widgets/feed_ad_loader_widget.dart | 5 +- lib/app/bloc/app_bloc.dart | 73 +++-- lib/app/bloc/app_state.dart | 4 +- lib/app/view/app.dart | 22 +- lib/bootstrap.dart | 3 +- .../bloc/entity_details_bloc.dart | 28 +- .../view/entity_details_page.dart | 13 +- .../bloc/headlines_filter_bloc.dart | 28 +- .../view/country_filter_page.dart | 37 +-- .../view/headlines_feed_page.dart | 68 +++-- .../view/headlines_filter_page.dart | 71 ++--- .../view/source_filter_page.dart | 122 ++++---- .../view/topic_filter_page.dart | 34 ++- .../bloc/headlines_search_bloc.dart | 3 +- .../view/headlines_search_page.dart | 264 +++++++++--------- lib/settings/view/feed_settings_page.dart | 2 +- 23 files changed, 505 insertions(+), 446 deletions(-) diff --git a/lib/account/view/manage_followed_items/countries/add_country_to_follow_page.dart b/lib/account/view/manage_followed_items/countries/add_country_to_follow_page.dart index 82e25607..ffa6795a 100644 --- a/lib/account/view/manage_followed_items/countries/add_country_to_follow_page.dart +++ b/lib/account/view/manage_followed_items/countries/add_country_to_follow_page.dart @@ -60,7 +60,7 @@ class AddCountryToFollowPage extends StatelessWidget { return BlocBuilder( buildWhen: (previous, current) => previous.userContentPreferences?.followedCountries != - current.userContentPreferences?.followedCountries, + current.userContentPreferences?.followedCountries, builder: (context, appState) { final userContentPreferences = appState.userContentPreferences; final followedCountries = @@ -152,25 +152,27 @@ class AddCountryToFollowPage extends StatelessWidget { onPressed: () { if (userContentPreferences == null) return; - final updatedFollowedCountries = - List.from(followedCountries); + final updatedFollowedCountries = List.from( + followedCountries, + ); if (isFollowed) { - updatedFollowedCountries - .removeWhere((c) => c.id == country.id); + updatedFollowedCountries.removeWhere( + (c) => c.id == country.id, + ); } else { updatedFollowedCountries.add(country); } - final updatedPreferences = - userContentPreferences.copyWith( - followedCountries: updatedFollowedCountries, - ); + final updatedPreferences = userContentPreferences + .copyWith( + followedCountries: updatedFollowedCountries, + ); context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, ), contentPadding: const EdgeInsets.symmetric( diff --git a/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart b/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart index 87a8effe..f0c09ef6 100644 --- a/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart +++ b/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart @@ -50,9 +50,7 @@ class FollowedCountriesListPage extends StatelessWidget { return FailureStateWidget( exception: appState.initialUserPreferencesError!, onRetry: () { - context.read().add( - AppStarted(initialUser: user), - ); + context.read().add(AppStarted(initialUser: user)); }, ); } @@ -89,20 +87,19 @@ class FollowedCountriesListPage extends StatelessWidget { ), tooltip: l10n.unfollowCountryTooltip(country.name), onPressed: () { - final updatedFollowedCountries = - List.from(followedCountries) - ..removeWhere((c) => c.id == country.id); + final updatedFollowedCountries = List.from( + followedCountries, + )..removeWhere((c) => c.id == country.id); - final updatedPreferences = - userContentPreferences.copyWith( + final updatedPreferences = userContentPreferences.copyWith( followedCountries: updatedFollowedCountries, ); context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, ), onTap: () async { diff --git a/lib/account/view/manage_followed_items/sources/add_source_to_follow_page.dart b/lib/account/view/manage_followed_items/sources/add_source_to_follow_page.dart index 9cd3ac40..236fb678 100644 --- a/lib/account/view/manage_followed_items/sources/add_source_to_follow_page.dart +++ b/lib/account/view/manage_followed_items/sources/add_source_to_follow_page.dart @@ -50,7 +50,7 @@ class AddSourceToFollowPage extends StatelessWidget { return BlocBuilder( buildWhen: (previous, current) => previous.userContentPreferences?.followedSources != - current.userContentPreferences?.followedSources, + current.userContentPreferences?.followedSources, builder: (context, appState) { final userContentPreferences = appState.userContentPreferences; final followedSources = @@ -82,25 +82,27 @@ class AddSourceToFollowPage extends StatelessWidget { onPressed: () { if (userContentPreferences == null) return; - final updatedFollowedSources = - List.from(followedSources); + final updatedFollowedSources = List.from( + followedSources, + ); if (isFollowed) { - updatedFollowedSources - .removeWhere((s) => s.id == source.id); + updatedFollowedSources.removeWhere( + (s) => s.id == source.id, + ); } else { updatedFollowedSources.add(source); } - final updatedPreferences = - userContentPreferences.copyWith( - followedSources: updatedFollowedSources, - ); + final updatedPreferences = userContentPreferences + .copyWith( + followedSources: updatedFollowedSources, + ); context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, ), ), diff --git a/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart b/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart index 56e0a8ed..908e5731 100644 --- a/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart +++ b/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart @@ -50,9 +50,7 @@ class FollowedSourcesListPage extends StatelessWidget { return FailureStateWidget( exception: appState.initialUserPreferencesError!, onRetry: () { - context.read().add( - AppStarted(initialUser: user), - ); + context.read().add(AppStarted(initialUser: user)); }, ); } @@ -86,20 +84,19 @@ class FollowedSourcesListPage extends StatelessWidget { ), tooltip: l10n.unfollowSourceTooltip(source.name), onPressed: () { - final updatedFollowedSources = - List.from(followedSources) - ..removeWhere((s) => s.id == source.id); + final updatedFollowedSources = List.from( + followedSources, + )..removeWhere((s) => s.id == source.id); - final updatedPreferences = - userContentPreferences.copyWith( + final updatedPreferences = userContentPreferences.copyWith( followedSources: updatedFollowedSources, ); context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, ), onTap: () async { diff --git a/lib/account/view/manage_followed_items/topics/add_topic_to_follow_page.dart b/lib/account/view/manage_followed_items/topics/add_topic_to_follow_page.dart index dfa14634..a4fcc8f0 100644 --- a/lib/account/view/manage_followed_items/topics/add_topic_to_follow_page.dart +++ b/lib/account/view/manage_followed_items/topics/add_topic_to_follow_page.dart @@ -60,7 +60,7 @@ class AddTopicToFollowPage extends StatelessWidget { return BlocBuilder( buildWhen: (previous, current) => previous.userContentPreferences?.followedTopics != - current.userContentPreferences?.followedTopics, + current.userContentPreferences?.followedTopics, builder: (context, appState) { final userContentPreferences = appState.userContentPreferences; final followedTopics = @@ -151,25 +151,27 @@ class AddTopicToFollowPage extends StatelessWidget { onPressed: () { if (userContentPreferences == null) return; - final updatedFollowedTopics = - List.from(followedTopics); + final updatedFollowedTopics = List.from( + followedTopics, + ); if (isFollowed) { - updatedFollowedTopics - .removeWhere((t) => t.id == topic.id); + updatedFollowedTopics.removeWhere( + (t) => t.id == topic.id, + ); } else { updatedFollowedTopics.add(topic); } - final updatedPreferences = - userContentPreferences.copyWith( - followedTopics: updatedFollowedTopics, - ); + final updatedPreferences = userContentPreferences + .copyWith( + followedTopics: updatedFollowedTopics, + ); context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, ), contentPadding: const EdgeInsets.symmetric( diff --git a/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart b/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart index 62aa862f..62a71d4f 100644 --- a/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart +++ b/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart @@ -50,9 +50,7 @@ class FollowedTopicsListPage extends StatelessWidget { return FailureStateWidget( exception: appState.initialUserPreferencesError!, onRetry: () { - context.read().add( - AppStarted(initialUser: user), - ); + context.read().add(AppStarted(initialUser: user)); }, ); } @@ -94,20 +92,19 @@ class FollowedTopicsListPage extends StatelessWidget { ), tooltip: l10n.unfollowTopicTooltip(topic.name), onPressed: () { - final updatedFollowedTopics = - List.from(followedTopics) - ..removeWhere((t) => t.id == topic.id); + final updatedFollowedTopics = List.from( + followedTopics, + )..removeWhere((t) => t.id == topic.id); - final updatedPreferences = - userContentPreferences.copyWith( + final updatedPreferences = userContentPreferences.copyWith( followedTopics: updatedFollowedTopics, ); context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, ), onTap: () async { diff --git a/lib/account/view/saved_headlines_page.dart b/lib/account/view/saved_headlines_page.dart index bacafe6e..73b70176 100644 --- a/lib/account/view/saved_headlines_page.dart +++ b/lib/account/view/saved_headlines_page.dart @@ -51,9 +51,7 @@ class SavedHeadlinesPage extends StatelessWidget { return FailureStateWidget( exception: appState.initialUserPreferencesError!, onRetry: () { - context.read().add( - AppStarted(initialUser: user), - ); + context.read().add(AppStarted(initialUser: user)); }, ); } @@ -95,27 +93,28 @@ class SavedHeadlinesPage extends StatelessWidget { ), itemBuilder: (context, index) { final headline = savedHeadlines[index]; - final imageStyle = appState.settings?.feedPreferences.headlineImageStyle ?? - HeadlineImageStyle.smallThumbnail; // Default if settings not loaded + final imageStyle = + appState.settings?.feedPreferences.headlineImageStyle ?? + HeadlineImageStyle + .smallThumbnail; // Default if settings not loaded final trailingButton = IconButton( icon: Icon(Icons.delete_outline, color: colorScheme.error), tooltip: l10n.headlineDetailsRemoveFromSavedTooltip, onPressed: () { - final updatedSavedHeadlines = - List.from(savedHeadlines) - ..removeWhere((h) => h.id == headline.id); + final updatedSavedHeadlines = List.from( + savedHeadlines, + )..removeWhere((h) => h.id == headline.id); - final updatedPreferences = - userContentPreferences.copyWith( + final updatedPreferences = userContentPreferences.copyWith( savedHeadlines: updatedSavedHeadlines, ); context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, ); diff --git a/lib/ads/widgets/feed_ad_loader_widget.dart b/lib/ads/widgets/feed_ad_loader_widget.dart index be7d5f75..5caad59a 100644 --- a/lib/ads/widgets/feed_ad_loader_widget.dart +++ b/lib/ads/widgets/feed_ad_loader_widget.dart @@ -204,7 +204,10 @@ class _FeedAdLoaderWidgetState extends State { } // Get the current HeadlineImageStyle from AppBloc - final headlineImageStyle = context.read().state.headlineImageStyle; + final headlineImageStyle = context + .read() + .state + .headlineImageStyle; // Call AdService.getFeedAd with the full AdConfig and adType from the placeholder. final loadedAd = await _adService.getFeedAd( diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 44f92808..09180a86 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -34,7 +34,7 @@ class AppBloc extends Bloc { required AuthRepository authenticationRepository, required DataRepository userAppSettingsRepository, required DataRepository - userContentPreferencesRepository, + userContentPreferencesRepository, required DataRepository appConfigRepository, required DataRepository userRepository, required local_config.AppEnvironment environment, @@ -44,29 +44,29 @@ class AppBloc extends Bloc { this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, - }) : _authenticationRepository = authenticationRepository, - _userAppSettingsRepository = userAppSettingsRepository, - _userContentPreferencesRepository = userContentPreferencesRepository, - _appConfigRepository = appConfigRepository, - _userRepository = userRepository, - _environment = environment, - _navigatorKey = navigatorKey, - _logger = Logger('AppBloc'), - super( - // Initial state of the app. The status is set to loadingUserData - // as the AppBloc will now handle fetching user-specific data. - // UserAppSettings and UserContentPreferences are initially null - // and will be fetched asynchronously. - AppState( - status: AppLifeCycleStatus.loadingUserData, - selectedBottomNavigationIndex: 0, - remoteConfig: initialRemoteConfig, // Use the pre-fetched config - initialRemoteConfigError: - initialRemoteConfigError, // Store any initial config error - environment: environment, - user: initialUser, // Set initial user if available - ), - ) { + }) : _authenticationRepository = authenticationRepository, + _userAppSettingsRepository = userAppSettingsRepository, + _userContentPreferencesRepository = userContentPreferencesRepository, + _appConfigRepository = appConfigRepository, + _userRepository = userRepository, + _environment = environment, + _navigatorKey = navigatorKey, + _logger = Logger('AppBloc'), + super( + // Initial state of the app. The status is set to loadingUserData + // as the AppBloc will now handle fetching user-specific data. + // UserAppSettings and UserContentPreferences are initially null + // and will be fetched asynchronously. + AppState( + status: AppLifeCycleStatus.loadingUserData, + selectedBottomNavigationIndex: 0, + remoteConfig: initialRemoteConfig, // Use the pre-fetched config + initialRemoteConfigError: + initialRemoteConfigError, // Store any initial config error + environment: environment, + user: initialUser, // Set initial user if available + ), + ) { // Register event handlers for various app-level events. on(_onAppStarted); on(_onAppUserChanged); @@ -89,7 +89,7 @@ class AppBloc extends Bloc { final AuthRepository _authenticationRepository; final DataRepository _userAppSettingsRepository; final DataRepository - _userContentPreferencesRepository; + _userContentPreferencesRepository; final DataRepository _appConfigRepository; final DataRepository _userRepository; final local_config.AppEnvironment _environment; @@ -131,9 +131,7 @@ class AppBloc extends Bloc { User user, Emitter emit, ) async { - _logger.info( - '[AppBloc] Fetching user settings for user: ${user.id}', - ); + _logger.info('[AppBloc] Fetching user settings for user: ${user.id}'); try { final userAppSettings = await _userAppSettingsRepository.read( id: user.id, @@ -308,8 +306,8 @@ class AppBloc extends Bloc { final newStatus = newUser == null ? AppLifeCycleStatus.unauthenticated : (newUser.appRole == AppUserRole.standardUser - ? AppLifeCycleStatus.authenticated - : AppLifeCycleStatus.anonymous); + ? AppLifeCycleStatus.authenticated + : AppLifeCycleStatus.anonymous); emit(state.copyWith(status: newStatus)); @@ -318,12 +316,7 @@ class AppBloc extends Bloc { await _fetchAndSetUserData(newUser, emit); } else { // If user logs out, clear user-specific data from state. - emit( - state.copyWith( - settings: null, - userContentPreferences: null, - ), - ); + emit(state.copyWith(settings: null, userContentPreferences: null)); } // In demo mode, ensure user-specific data is initialized. @@ -638,7 +631,9 @@ class AppBloc extends Bloc { '(HttpException): $e', ); // Revert to original preferences on failure to maintain state consistency. - emit(state.copyWith(userContentPreferences: state.userContentPreferences)); + emit( + state.copyWith(userContentPreferences: state.userContentPreferences), + ); } catch (e, s) { _logger.severe( 'Unexpected error persisting UserContentPreferences for user ${updatedPreferences.id}.', @@ -646,7 +641,9 @@ class AppBloc extends Bloc { s, ); // Revert to original preferences on failure to maintain state consistency. - emit(state.copyWith(userContentPreferences: state.userContentPreferences)); + emit( + state.copyWith(userContentPreferences: state.userContentPreferences), + ); } } } diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index 7bbf624e..2d1ff374 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -90,8 +90,8 @@ class AppState extends Equatable { return settings?.displaySettings.baseTheme == AppBaseTheme.light ? ThemeMode.light : (settings?.displaySettings.baseTheme == AppBaseTheme.dark - ? ThemeMode.dark - : ThemeMode.system); + ? ThemeMode.dark + : ThemeMode.system); } /// The current FlexColorScheme scheme for accent colors, derived from [settings]. diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 61453674..b360060c 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -30,8 +30,26 @@ import 'package:ui_kit/ui_kit.dart'; /// {@endtemplate} class App extends StatelessWidget { /// {@macro app_widget} - const App({required AuthRepository authenticationRepository, required DataRepository headlinesRepository, required DataRepository topicsRepository, required DataRepository countriesRepository, required DataRepository sourcesRepository, required DataRepository userAppSettingsRepository, required DataRepository - userContentPreferencesRepository, required DataRepository remoteConfigRepository, required DataRepository userRepository, required KVStorageService kvStorageService, required AppEnvironment environment, required AdService adService, required DataRepository localAdRepository, required GlobalKey navigatorKey, required InlineAdCacheService inlineAdCacheService, required RemoteConfig? initialRemoteConfig, required HttpException? initialRemoteConfigError, super.key, + const App({ + required AuthRepository authenticationRepository, + required DataRepository headlinesRepository, + required DataRepository topicsRepository, + required DataRepository countriesRepository, + required DataRepository sourcesRepository, + required DataRepository userAppSettingsRepository, + required DataRepository + userContentPreferencesRepository, + required DataRepository remoteConfigRepository, + required DataRepository userRepository, + required KVStorageService kvStorageService, + required AppEnvironment environment, + required AdService adService, + required DataRepository localAdRepository, + required GlobalKey navigatorKey, + required InlineAdCacheService inlineAdCacheService, + required RemoteConfig? initialRemoteConfig, + required HttpException? initialRemoteConfigError, + super.key, this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 63b46ef0..cf8869bd 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -449,6 +449,7 @@ Future bootstrap( localAdRepository: localAdRepository, navigatorKey: navigatorKey, // Pass the navigatorKey to App initialRemoteConfig: initialRemoteConfig, // Pass the initialRemoteConfig - initialRemoteConfigError: initialRemoteConfigError, // Pass the initialRemoteConfigError + initialRemoteConfigError: + initialRemoteConfigError, // Pass the initialRemoteConfigError ); } diff --git a/lib/entity_details/bloc/entity_details_bloc.dart b/lib/entity_details/bloc/entity_details_bloc.dart index f687c6c4..e76940c7 100644 --- a/lib/entity_details/bloc/entity_details_bloc.dart +++ b/lib/entity_details/bloc/entity_details_bloc.dart @@ -122,13 +122,14 @@ class EntityDetailsBloc extends Bloc { // This method injects stateless `AdPlaceholder` markers into the feed. // The full ad loading and lifecycle is managed by the UI layer. // See `FeedDecoratorService` for a detailed explanation. - final processedFeedItems = await _feedDecoratorService.injectAdPlaceholders( - feedItems: headlineResponse.items, - user: currentUser, - adConfig: remoteConfig.adConfig, - imageStyle: _appBloc.state.headlineImageStyle, - adThemeStyle: event.adThemeStyle, - ); + final processedFeedItems = await _feedDecoratorService + .injectAdPlaceholders( + feedItems: headlineResponse.items, + user: currentUser, + adConfig: remoteConfig.adConfig, + imageStyle: _appBloc.state.headlineImageStyle, + adThemeStyle: event.adThemeStyle, + ); // 3. Determine isFollowing status from AppBloc's user preferences var isCurrentlyFollowing = false; @@ -192,9 +193,15 @@ class EntityDetailsBloc extends Bloc { } // Create a mutable copy of the lists to modify - final updatedFollowedTopics = List.from(currentPreferences.followedTopics); - final updatedFollowedSources = List.from(currentPreferences.followedSources); - final updatedFollowedCountries = List.from(currentPreferences.followedCountries); + final updatedFollowedTopics = List.from( + currentPreferences.followedTopics, + ); + final updatedFollowedSources = List.from( + currentPreferences.followedSources, + ); + final updatedFollowedCountries = List.from( + currentPreferences.followedCountries, + ); var isCurrentlyFollowing = false; @@ -326,5 +333,4 @@ class EntityDetailsBloc extends Bloc { ); } } - } diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index bb84418e..a7841380 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -325,8 +325,10 @@ class _EntityDetailsViewState extends State { final item = state.feedItems[index]; if (item is Headline) { - final imageStyle = - context.read().state.headlineImageStyle; + final imageStyle = context + .read() + .state + .headlineImageStyle; Widget tile; switch (imageStyle) { case HeadlineImageStyle.hidden: @@ -350,8 +352,11 @@ class _EntityDetailsViewState extends State { // Retrieve the user's preferred headline image style from the AppBloc. // This is the single source of truth for this setting. // Access the AppBloc to get the remoteConfig for ads. - final adConfig = - context.read().state.remoteConfig?.adConfig; + final adConfig = context + .read() + .state + .remoteConfig + ?.adConfig; // Ensure adConfig is not null before building the AdLoaderWidget. if (adConfig == null) { diff --git a/lib/headlines-feed/bloc/headlines_filter_bloc.dart b/lib/headlines-feed/bloc/headlines_filter_bloc.dart index 6343f4a4..205b1a19 100644 --- a/lib/headlines-feed/bloc/headlines_filter_bloc.dart +++ b/lib/headlines-feed/bloc/headlines_filter_bloc.dart @@ -19,7 +19,8 @@ part 'headlines_filter_state.dart'; /// as they interact with the filter UI. It also integrates with the [AppBloc] /// to access user-specific content preferences for "followed items" functionality. /// {@endtemplate} -class HeadlinesFilterBloc extends Bloc { +class HeadlinesFilterBloc + extends Bloc { /// {@macro headlines_filter_bloc} /// /// Requires repositories for topics, sources, and countries, as well as @@ -29,12 +30,12 @@ class HeadlinesFilterBloc extends Bloc sourcesRepository, required DataRepository countriesRepository, required AppBloc appBloc, - }) : _topicsRepository = topicsRepository, - _sourcesRepository = sourcesRepository, - _countriesRepository = countriesRepository, - _appBloc = appBloc, - _logger = Logger('HeadlinesFilterBloc'), - super(const HeadlinesFilterState()) { + }) : _topicsRepository = topicsRepository, + _sourcesRepository = sourcesRepository, + _countriesRepository = countriesRepository, + _appBloc = appBloc, + _logger = Logger('HeadlinesFilterBloc'), + super(const HeadlinesFilterState()) { on(_onFilterDataLoaded, transformer: restartable()); on(_onFilterTopicToggled); on(_onFilterSourceToggled); @@ -74,7 +75,9 @@ class HeadlinesFilterBloc extends Bloc().add( - FollowedItemsFilterToggled( - isUsingFollowedItems: !isFollowedFilterActive, - ), - ); + FollowedItemsFilterToggled( + isUsingFollowedItems: !isFollowedFilterActive, + ), + ); } }, ); @@ -109,13 +110,14 @@ class CountryFilterPage extends StatelessWidget { exception: filterState.error ?? const UnknownException('Unknown error'), onRetry: () => context.read().add( - FilterDataLoaded( - initialSelectedTopics: filterState.selectedTopics.toList(), - initialSelectedSources: filterState.selectedSources.toList(), - initialSelectedCountries: filterState.selectedCountries.toList(), - isUsingFollowedItems: filterState.isUsingFollowedItems, - ), - ), + FilterDataLoaded( + initialSelectedTopics: filterState.selectedTopics.toList(), + initialSelectedSources: filterState.selectedSources.toList(), + initialSelectedCountries: filterState.selectedCountries + .toList(), + isUsingFollowedItems: filterState.isUsingFollowedItems, + ), + ), ); } @@ -136,7 +138,9 @@ class CountryFilterPage extends StatelessWidget { itemCount: filterState.allCountries.length, itemBuilder: (context, index) { final country = filterState.allCountries[index]; - final isSelected = filterState.selectedCountries.contains(country); + final isSelected = filterState.selectedCountries.contains( + country, + ); return CheckboxListTile( title: Text(country.name, style: textTheme.titleMedium), @@ -173,11 +177,8 @@ class CountryFilterPage extends StatelessWidget { onChanged: (bool? value) { if (value != null) { context.read().add( - FilterCountryToggled( - country: country, - isSelected: value, - ), - ); + FilterCountryToggled(country: country, isSelected: value), + ); } }, controlAffinity: ListTileControlAffinity.leading, diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index 8a0c1618..dd43efbf 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -147,9 +147,7 @@ class _HeadlinesFeedPageState extends State { tooltip: l10n.headlinesFeedFilterTooltip, onPressed: () { // Navigate to the filter page route - context.goNamed( - Routes.feedFilterName, - ); + context.goNamed(Routes.feedFilterName); }, ), if (isFilterApplied) @@ -298,8 +296,10 @@ class _HeadlinesFeedPageState extends State { final item = state.feedItems[index]; if (item is Headline) { - final imageStyle = - context.watch().state.headlineImageStyle; + final imageStyle = context + .watch() + .state + .headlineImageStyle; Widget tile; switch (imageStyle) { case HeadlineImageStyle.hidden: @@ -334,7 +334,9 @@ class _HeadlinesFeedPageState extends State { } return FeedAdLoaderWidget( - key: ValueKey(item.id), // Add a unique key for AdPlaceholder + key: ValueKey( + item.id, + ), // Add a unique key for AdPlaceholder adPlaceholder: item, adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), adConfig: adConfig, @@ -364,10 +366,12 @@ class _HeadlinesFeedPageState extends State { final followedSources = appState.userContentPreferences?.followedSources ?? []; - final followedTopicIds = - followedTopics.map((t) => t.id).toList(); - final followedSourceIds = - followedSources.map((s) => s.id).toList(); + final followedTopicIds = followedTopics + .map((t) => t.id) + .toList(); + final followedSourceIds = followedSources + .map((s) => s.id) + .toList(); return ContentCollectionDecoratorWidget( item: item, @@ -381,13 +385,15 @@ class _HeadlinesFeedPageState extends State { UserContentPreferences updatedPreferences; if (toggledItem is Topic) { - final isCurrentlyFollowing = - followedTopicIds.contains(toggledItem.id); - final newFollowedTopics = - List.from(followedTopics); + final isCurrentlyFollowing = followedTopicIds + .contains(toggledItem.id); + final newFollowedTopics = List.from( + followedTopics, + ); if (isCurrentlyFollowing) { - newFollowedTopics - .removeWhere((t) => t.id == toggledItem.id); + newFollowedTopics.removeWhere( + (t) => t.id == toggledItem.id, + ); } else { newFollowedTopics.add(toggledItem); } @@ -395,13 +401,15 @@ class _HeadlinesFeedPageState extends State { followedTopics: newFollowedTopics, ); } else if (toggledItem is Source) { - final isCurrentlyFollowing = - followedSourceIds.contains(toggledItem.id); - final newFollowedSources = - List.from(followedSources); + final isCurrentlyFollowing = followedSourceIds + .contains(toggledItem.id); + final newFollowedSources = List.from( + followedSources, + ); if (isCurrentlyFollowing) { - newFollowedSources - .removeWhere((s) => s.id == toggledItem.id); + newFollowedSources.removeWhere( + (s) => s.id == toggledItem.id, + ); } else { newFollowedSources.add(toggledItem); } @@ -413,17 +421,17 @@ class _HeadlinesFeedPageState extends State { } context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); + AppUserContentPreferencesChanged( + preferences: updatedPreferences, + ), + ); }, onDismiss: (decoratorType) { context.read().add( - FeedDecoratorDismissed( - feedDecoratorType: decoratorType, - ), - ); + FeedDecoratorDismissed( + feedDecoratorType: decoratorType, + ), + ); }, ); } diff --git a/lib/headlines-feed/view/headlines_filter_page.dart b/lib/headlines-feed/view/headlines_filter_page.dart index 4fd1e640..9cbac191 100644 --- a/lib/headlines-feed/view/headlines_filter_page.dart +++ b/lib/headlines-feed/view/headlines_filter_page.dart @@ -33,19 +33,20 @@ class HeadlinesFilterPage extends StatelessWidget { final currentFilter = headlinesFeedBloc.state.filter; return BlocProvider( - create: (context) => HeadlinesFilterBloc( - topicsRepository: context.read>(), - sourcesRepository: context.read>(), - countriesRepository: context.read>(), - appBloc: context.read(), - )..add( - FilterDataLoaded( - initialSelectedTopics: currentFilter.topics ?? [], - initialSelectedSources: currentFilter.sources ?? [], - initialSelectedCountries: currentFilter.eventCountries ?? [], - isUsingFollowedItems: currentFilter.isFromFollowedItems, + create: (context) => + HeadlinesFilterBloc( + topicsRepository: context.read>(), + sourcesRepository: context.read>(), + countriesRepository: context.read>(), + appBloc: context.read(), + )..add( + FilterDataLoaded( + initialSelectedTopics: currentFilter.topics ?? [], + initialSelectedSources: currentFilter.sources ?? [], + initialSelectedCountries: currentFilter.eventCountries ?? [], + isUsingFollowedItems: currentFilter.isFromFollowedItems, + ), ), - ), child: const _HeadlinesFilterView(), ); } @@ -109,8 +110,8 @@ class _HeadlinesFilterView extends StatelessWidget { tooltip: l10n.headlinesFeedFilterResetButton, onPressed: () { context.read().add( - const FilterSelectionsCleared(), - ); + const FilterSelectionsCleared(), + ); }, ), // Apply My Followed Items Button @@ -124,7 +125,8 @@ class _HeadlinesFilterView extends StatelessWidget { final followedCountries = appState.userContentPreferences?.followedCountries ?? []; - final hasFollowedItems = followedTopics.isNotEmpty || + final hasFollowedItems = + followedTopics.isNotEmpty || followedSources.isNotEmpty || followedCountries.isNotEmpty; @@ -139,11 +141,11 @@ class _HeadlinesFilterView extends StatelessWidget { onPressed: hasFollowedItems ? () { context.read().add( - FollowedItemsFilterToggled( - isUsingFollowedItems: - !filterState.isUsingFollowedItems, - ), - ); + FollowedItemsFilterToggled( + isUsingFollowedItems: + !filterState.isUsingFollowedItems, + ), + ); } : () { ScaffoldMessenger.of(context) @@ -179,11 +181,11 @@ class _HeadlinesFilterView extends StatelessWidget { isFromFollowedItems: filterState.isUsingFollowedItems, ); context.read().add( - HeadlinesFeedFiltersApplied( - filter: newFilter, - adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), - ), - ); + HeadlinesFeedFiltersApplied( + filter: newFilter, + adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), + ), + ); context.pop(); }, ), @@ -205,20 +207,21 @@ class _HeadlinesFilterView extends StatelessWidget { if (filterState.status == HeadlinesFilterStatus.failure) { return FailureStateWidget( - exception: filterState.error ?? + exception: + filterState.error ?? const UnknownException('Failed to load filter data.'), onRetry: () { final headlinesFeedBloc = context.read(); final currentFilter = headlinesFeedBloc.state.filter; context.read().add( - FilterDataLoaded( - initialSelectedTopics: currentFilter.topics ?? [], - initialSelectedSources: currentFilter.sources ?? [], - initialSelectedCountries: - currentFilter.eventCountries ?? [], - isUsingFollowedItems: currentFilter.isFromFollowedItems, - ), - ); + FilterDataLoaded( + initialSelectedTopics: currentFilter.topics ?? [], + initialSelectedSources: currentFilter.sources ?? [], + initialSelectedCountries: + currentFilter.eventCountries ?? [], + isUsingFollowedItems: currentFilter.isFromFollowedItems, + ), + ); }, ); } diff --git a/lib/headlines-feed/view/source_filter_page.dart b/lib/headlines-feed/view/source_filter_page.dart index c98cbfbb..2c108836 100644 --- a/lib/headlines-feed/view/source_filter_page.dart +++ b/lib/headlines-feed/view/source_filter_page.dart @@ -51,8 +51,10 @@ class _SourceFilterView extends StatelessWidget { appState.userContentPreferences?.followedSources ?? []; // Determine if the current selection matches the followed sources - final isFollowedFilterActive = followedSources.isNotEmpty && - filterState.selectedSources.length == followedSources.length && + final isFollowedFilterActive = + followedSources.isNotEmpty && + filterState.selectedSources.length == + followedSources.length && filterState.selectedSources.containsAll(followedSources); return IconButton( @@ -76,10 +78,10 @@ class _SourceFilterView extends StatelessWidget { } else { // Toggle the followed items filter in the HeadlinesFilterBloc context.read().add( - FollowedItemsFilterToggled( - isUsingFollowedItems: !isFollowedFilterActive, - ), - ); + FollowedItemsFilterToggled( + isUsingFollowedItems: !isFollowedFilterActive, + ), + ); } }, ); @@ -113,30 +115,34 @@ class _SourceFilterView extends StatelessWidget { if (filterState.status == HeadlinesFilterStatus.failure && filterState.allSources.isEmpty) { return FailureStateWidget( - exception: filterState.error ?? - const UnknownException( - 'Failed to load source filter data.', - ), + exception: + filterState.error ?? + const UnknownException('Failed to load source filter data.'), onRetry: () { context.read().add( - FilterDataLoaded( - initialSelectedTopics: filterState.selectedTopics.toList(), - initialSelectedSources: filterState.selectedSources.toList(), - initialSelectedCountries: filterState.selectedCountries.toList(), - isUsingFollowedItems: filterState.isUsingFollowedItems, - ), - ); + FilterDataLoaded( + initialSelectedTopics: filterState.selectedTopics.toList(), + initialSelectedSources: filterState.selectedSources + .toList(), + initialSelectedCountries: filterState.selectedCountries + .toList(), + isUsingFollowedItems: filterState.isUsingFollowedItems, + ), + ); }, ); } // Filter sources based on selected countries and types from HeadlinesFilterBloc final displayableSources = filterState.allSources.where((source) { - final matchesCountry = filterState.selectedCountries.isEmpty || - filterState.selectedCountries - .any((c) => c.isoCode == source.headquarters.isoCode); + final matchesCountry = + filterState.selectedCountries.isEmpty || + filterState.selectedCountries.any( + (c) => c.isoCode == source.headquarters.isoCode, + ); // Assuming all source types are available and selected by default if none are explicitly selected - final matchesType = filterState.selectedSources.isEmpty || + final matchesType = + filterState.selectedSources.isEmpty || filterState.selectedSources.contains(source); return matchesCountry && matchesType; }).toList(); @@ -225,11 +231,11 @@ class _SourceFilterView extends StatelessWidget { // Clear all country selections for (final country in filterState.allCountries) { context.read().add( - FilterCountryToggled( - country: country, - isSelected: false, - ), - ); + FilterCountryToggled( + country: country, + isSelected: false, + ), + ); } }, ); @@ -247,11 +253,11 @@ class _SourceFilterView extends StatelessWidget { selected: filterState.selectedCountries.contains(country), onSelected: (isSelected) { context.read().add( - FilterCountryToggled( - country: country, - isSelected: isSelected, - ), - ); + FilterCountryToggled( + country: country, + isSelected: isSelected, + ), + ); }, ); }, @@ -269,11 +275,9 @@ class _SourceFilterView extends StatelessWidget { TextTheme textTheme, ) { // For source types, we need to get all unique source types from all available sources - final allSourceTypes = filterState.allSources - .map((s) => s.sourceType) - .toSet() - .toList() - ..sort((a, b) => a.name.compareTo(b.name)); + final allSourceTypes = + filterState.allSources.map((s) => s.sourceType).toSet().toList() + ..sort((a, b) => a.name.compareTo(b.name)); // Determine which source types are currently selected based on the selected sources final selectedSourceTypes = filterState.selectedSources @@ -307,11 +311,11 @@ class _SourceFilterView extends StatelessWidget { // Clear all source selections for (final source in filterState.allSources) { context.read().add( - FilterSourceToggled( - source: source, - isSelected: false, - ), - ); + FilterSourceToggled( + source: source, + isSelected: false, + ), + ); } }, ); @@ -323,14 +327,15 @@ class _SourceFilterView extends StatelessWidget { selected: selectedSourceTypes.contains(sourceType), onSelected: (isSelected) { // Toggle all sources of this type - for (final source in filterState.allSources - .where((s) => s.sourceType == sourceType)) { + for (final source in filterState.allSources.where( + (s) => s.sourceType == sourceType, + )) { context.read().add( - FilterSourceToggled( - source: source, - isSelected: isSelected, - ), - ); + FilterSourceToggled( + source: source, + isSelected: isSelected, + ), + ); } }, ); @@ -356,17 +361,18 @@ class _SourceFilterView extends StatelessWidget { if (filterState.status == HeadlinesFilterStatus.failure && displayableSources.isEmpty) { return FailureStateWidget( - exception: filterState.error ?? + exception: + filterState.error ?? const UnknownException('Failed to load displayable sources.'), onRetry: () { context.read().add( - FilterDataLoaded( - initialSelectedTopics: filterState.selectedTopics.toList(), - initialSelectedSources: filterState.selectedSources.toList(), - initialSelectedCountries: filterState.selectedCountries.toList(), - isUsingFollowedItems: filterState.isUsingFollowedItems, - ), - ); + FilterDataLoaded( + initialSelectedTopics: filterState.selectedTopics.toList(), + initialSelectedSources: filterState.selectedSources.toList(), + initialSelectedCountries: filterState.selectedCountries.toList(), + isUsingFollowedItems: filterState.isUsingFollowedItems, + ), + ); }, ); } @@ -397,8 +403,8 @@ class _SourceFilterView extends StatelessWidget { onChanged: (bool? value) { if (value != null) { context.read().add( - FilterSourceToggled(source: source, isSelected: value), - ); + FilterSourceToggled(source: source, isSelected: value), + ); } }, controlAffinity: ListTileControlAffinity.leading, diff --git a/lib/headlines-feed/view/topic_filter_page.dart b/lib/headlines-feed/view/topic_filter_page.dart index 0c1b544d..ff95c445 100644 --- a/lib/headlines-feed/view/topic_filter_page.dart +++ b/lib/headlines-feed/view/topic_filter_page.dart @@ -36,7 +36,8 @@ class TopicFilterPage extends StatelessWidget { appState.userContentPreferences?.followedTopics ?? []; // Determine if the current selection matches the followed topics - final isFollowedFilterActive = followedTopics.isNotEmpty && + final isFollowedFilterActive = + followedTopics.isNotEmpty && filterState.selectedTopics.length == followedTopics.length && filterState.selectedTopics.containsAll(followedTopics); @@ -61,10 +62,10 @@ class TopicFilterPage extends StatelessWidget { } else { // Toggle the followed items filter in the HeadlinesFilterBloc context.read().add( - FollowedItemsFilterToggled( - isUsingFollowedItems: !isFollowedFilterActive, - ), - ); + FollowedItemsFilterToggled( + isUsingFollowedItems: !isFollowedFilterActive, + ), + ); } }, ); @@ -100,18 +101,21 @@ class TopicFilterPage extends StatelessWidget { filterState.allTopics.isEmpty) { return Center( child: FailureStateWidget( - exception: filterState.error ?? + exception: + filterState.error ?? const UnknownException( 'An unknown error occurred while fetching topics.', ), onRetry: () => context.read().add( - FilterDataLoaded( - initialSelectedTopics: filterState.selectedTopics.toList(), - initialSelectedSources: filterState.selectedSources.toList(), - initialSelectedCountries: filterState.selectedCountries.toList(), - isUsingFollowedItems: filterState.isUsingFollowedItems, - ), - ), + FilterDataLoaded( + initialSelectedTopics: filterState.selectedTopics.toList(), + initialSelectedSources: filterState.selectedSources + .toList(), + initialSelectedCountries: filterState.selectedCountries + .toList(), + isUsingFollowedItems: filterState.isUsingFollowedItems, + ), + ), ), ); } @@ -135,8 +139,8 @@ class TopicFilterPage extends StatelessWidget { onChanged: (bool? value) { if (value != null) { context.read().add( - FilterTopicToggled(topic: topic, isSelected: value), - ); + FilterTopicToggled(topic: topic, isSelected: value), + ); } }, ); diff --git a/lib/headlines-search/bloc/headlines_search_bloc.dart b/lib/headlines-search/bloc/headlines_search_bloc.dart index 1e4c1192..8d24219e 100644 --- a/lib/headlines-search/bloc/headlines_search_bloc.dart +++ b/lib/headlines-search/bloc/headlines_search_bloc.dart @@ -261,8 +261,7 @@ class HeadlinesSearchBloc feedItems: headlines, user: currentUser, adConfig: appConfig.adConfig, - imageStyle: - _appBloc.state.headlineImageStyle, // Use AppBloc getter + imageStyle: _appBloc.state.headlineImageStyle, // Use AppBloc getter adThemeStyle: event.adThemeStyle, ); case ContentType.topic: diff --git a/lib/headlines-search/view/headlines_search_page.dart b/lib/headlines-search/view/headlines_search_page.dart index 52c1ad54..6f7358a1 100644 --- a/lib/headlines-search/view/headlines_search_page.dart +++ b/lib/headlines-search/view/headlines_search_page.dart @@ -57,11 +57,13 @@ class _HeadlinesSearchPageState extends State { // Initialize the selected model type from the BLoC's initial state. // This ensures consistency if the BLoC was already initialized with a // specific type (e.g., after a hot restart). - final initialModelType = - context.read().state.selectedModelType; + final initialModelType = context + .read() + .state + .selectedModelType; context.read().add( - HeadlinesSearchModelTypeChanged(initialModelType), - ); + HeadlinesSearchModelTypeChanged(initialModelType), + ); } @override @@ -77,11 +79,11 @@ class _HeadlinesSearchPageState extends State { final state = context.read().state; if (_isBottom && state is HeadlinesSearchSuccess && state.hasMore) { context.read().add( - HeadlinesSearchFetchRequested( - searchTerm: state.lastSearchTerm, - adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), - ), - ); + HeadlinesSearchFetchRequested( + searchTerm: state.lastSearchTerm, + adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), + ), + ); } } @@ -94,11 +96,11 @@ class _HeadlinesSearchPageState extends State { void _performSearch() { context.read().add( - HeadlinesSearchFetchRequested( - searchTerm: _textController.text, - adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), - ), - ); + HeadlinesSearchFetchRequested( + searchTerm: _textController.text, + adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), + ), + ); } @override @@ -131,8 +133,8 @@ class _HeadlinesSearchPageState extends State { // If not, default to headline. final currentSelectedModelType = availableSearchModelTypes.contains(state.selectedModelType) - ? state.selectedModelType - : ContentType.headline; + ? state.selectedModelType + : ContentType.headline; return SizedBox( width: 150, @@ -147,13 +149,15 @@ class _HeadlinesSearchPageState extends State { isDense: true, ), style: textTheme.titleMedium?.copyWith( - color: appBarTheme.titleTextStyle?.color ?? + color: + appBarTheme.titleTextStyle?.color ?? colorScheme.onSurface, ), dropdownColor: colorScheme.surfaceContainerHighest, icon: Icon( Icons.arrow_drop_down_rounded, - color: appBarTheme.iconTheme?.color ?? + color: + appBarTheme.iconTheme?.color ?? colorScheme.onSurfaceVariant, ), items: availableSearchModelTypes.map((ContentType type) { @@ -165,8 +169,8 @@ class _HeadlinesSearchPageState extends State { onChanged: (ContentType? newValue) { if (newValue != null) { context.read().add( - HeadlinesSearchModelTypeChanged(newValue), - ); + HeadlinesSearchModelTypeChanged(newValue), + ); } }, ), @@ -181,9 +185,10 @@ class _HeadlinesSearchPageState extends State { decoration: InputDecoration( hintText: l10n.searchHintTextGeneric, hintStyle: textTheme.bodyMedium?.copyWith( - color: (appBarTheme.titleTextStyle?.color ?? - colorScheme.onSurface) - .withOpacity(0.6), + color: + (appBarTheme.titleTextStyle?.color ?? + colorScheme.onSurface) + .withOpacity(0.6), ), border: InputBorder.none, filled: false, @@ -195,7 +200,8 @@ class _HeadlinesSearchPageState extends State { ? IconButton( icon: Icon( Icons.clear_rounded, - color: appBarTheme.iconTheme?.color ?? + color: + appBarTheme.iconTheme?.color ?? colorScheme.onSurfaceVariant, ), onPressed: _textController.clear, @@ -220,17 +226,17 @@ class _HeadlinesSearchPageState extends State { builder: (context, state) { return switch (state) { HeadlinesSearchInitial() => InitialStateWidget( - icon: Icons.search_outlined, - headline: l10n.searchPageInitialHeadline, - subheadline: l10n.searchPageInitialSubheadline, - ), + icon: Icons.search_outlined, + headline: l10n.searchPageInitialHeadline, + subheadline: l10n.searchPageInitialSubheadline, + ), HeadlinesSearchLoading() => LoadingStateWidget( - icon: Icons.search_outlined, - headline: l10n.headlinesFeedLoadingHeadline, - subheadline: l10n.searchingFor( - state.selectedModelType.displayName(context).toLowerCase(), - ), + icon: Icons.search_outlined, + headline: l10n.headlinesFeedLoadingHeadline, + subheadline: l10n.searchingFor( + state.selectedModelType.displayName(context).toLowerCase(), ), + ), HeadlinesSearchSuccess( items: final items, hasMore: final hasMore, @@ -242,105 +248,105 @@ class _HeadlinesSearchPageState extends State { ? FailureStateWidget( exception: UnknownException(errorMessage), onRetry: () => context.read().add( - HeadlinesSearchFetchRequested( - searchTerm: lastSearchTerm, - adThemeStyle: AdThemeStyle.fromTheme(theme), - ), - ), + HeadlinesSearchFetchRequested( + searchTerm: lastSearchTerm, + adThemeStyle: AdThemeStyle.fromTheme(theme), + ), + ), ) : items.isEmpty - ? InitialStateWidget( - icon: Icons.search_off_outlined, - headline: l10n.headlinesSearchNoResultsHeadline, - subheadline: - 'For "$lastSearchTerm" in ${resultsModelType.displayName(context).toLowerCase()}.\n${l10n.headlinesSearchNoResultsSubheadline}', - ) - : ListView.separated( - controller: _scrollController, - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingMedium, - vertical: AppSpacing.paddingSmall, - ).copyWith(bottom: AppSpacing.xxl), - itemCount: hasMore ? items.length + 1 : items.length, - separatorBuilder: (context, index) => - const SizedBox(height: AppSpacing.sm), - itemBuilder: (context, index) { - if (index >= items.length) { - return const Padding( - padding: EdgeInsets.symmetric( - vertical: AppSpacing.lg, - ), - child: Center(child: CircularProgressIndicator()), - ); - } - final feedItem = items[index]; - - if (feedItem is Headline) { - final imageStyle = context - .watch() - .state - .headlineImageStyle; // Use AppBloc getter - Widget tile; - Future onHeadlineTap() async { - await context - .read() - .onPotentialAdTrigger(); - - if (!context.mounted) return; + ? InitialStateWidget( + icon: Icons.search_off_outlined, + headline: l10n.headlinesSearchNoResultsHeadline, + subheadline: + 'For "$lastSearchTerm" in ${resultsModelType.displayName(context).toLowerCase()}.\n${l10n.headlinesSearchNoResultsSubheadline}', + ) + : ListView.separated( + controller: _scrollController, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.paddingSmall, + ).copyWith(bottom: AppSpacing.xxl), + itemCount: hasMore ? items.length + 1 : items.length, + separatorBuilder: (context, index) => + const SizedBox(height: AppSpacing.sm), + itemBuilder: (context, index) { + if (index >= items.length) { + return const Padding( + padding: EdgeInsets.symmetric( + vertical: AppSpacing.lg, + ), + child: Center(child: CircularProgressIndicator()), + ); + } + final feedItem = items[index]; - await context.pushNamed( - Routes.searchArticleDetailsName, - pathParameters: {'id': feedItem.id}, - extra: feedItem, - ); - } + if (feedItem is Headline) { + final imageStyle = context + .watch() + .state + .headlineImageStyle; // Use AppBloc getter + Widget tile; + Future onHeadlineTap() async { + await context + .read() + .onPotentialAdTrigger(); - switch (imageStyle) { - case HeadlineImageStyle.hidden: - tile = HeadlineTileTextOnly( - headline: feedItem, - onHeadlineTap: onHeadlineTap, - ); - case HeadlineImageStyle.smallThumbnail: - tile = HeadlineTileImageStart( - headline: feedItem, - onHeadlineTap: onHeadlineTap, - ); - case HeadlineImageStyle.largeThumbnail: - tile = HeadlineTileImageTop( - headline: feedItem, - onHeadlineTap: onHeadlineTap, - ); - } - return tile; - } else if (feedItem is Topic) { - return TopicItemWidget(topic: feedItem); - } else if (feedItem is Source) { - return SourceItemWidget(source: feedItem); - } else if (feedItem is Country) { - return CountryItemWidget(country: feedItem); - } else if (feedItem is AdPlaceholder) { - final adConfig = context - .watch() - .state - .remoteConfig - ?.adConfig; // Use AppBloc getter + if (!context.mounted) return; - if (adConfig == null) { - return const SizedBox.shrink(); - } + await context.pushNamed( + Routes.searchArticleDetailsName, + pathParameters: {'id': feedItem.id}, + extra: feedItem, + ); + } - return FeedAdLoaderWidget( - adPlaceholder: feedItem, - adThemeStyle: AdThemeStyle.fromTheme( - Theme.of(context), - ), - adConfig: adConfig, + switch (imageStyle) { + case HeadlineImageStyle.hidden: + tile = HeadlineTileTextOnly( + headline: feedItem, + onHeadlineTap: onHeadlineTap, + ); + case HeadlineImageStyle.smallThumbnail: + tile = HeadlineTileImageStart( + headline: feedItem, + onHeadlineTap: onHeadlineTap, ); - } + case HeadlineImageStyle.largeThumbnail: + tile = HeadlineTileImageTop( + headline: feedItem, + onHeadlineTap: onHeadlineTap, + ); + } + return tile; + } else if (feedItem is Topic) { + return TopicItemWidget(topic: feedItem); + } else if (feedItem is Source) { + return SourceItemWidget(source: feedItem); + } else if (feedItem is Country) { + return CountryItemWidget(country: feedItem); + } else if (feedItem is AdPlaceholder) { + final adConfig = context + .watch() + .state + .remoteConfig + ?.adConfig; // Use AppBloc getter + + if (adConfig == null) { return const SizedBox.shrink(); - }, - ), + } + + return FeedAdLoaderWidget( + adPlaceholder: feedItem, + adThemeStyle: AdThemeStyle.fromTheme( + Theme.of(context), + ), + adConfig: adConfig, + ); + } + return const SizedBox.shrink(); + }, + ), HeadlinesSearchFailure( errorMessage: final errorMessage, lastSearchTerm: final lastSearchTerm, @@ -351,11 +357,11 @@ class _HeadlinesSearchPageState extends State { 'Failed to search "$lastSearchTerm" in ${failedModelType.displayName(context).toLowerCase()}:\n$errorMessage', ), onRetry: () => context.read().add( - HeadlinesSearchFetchRequested( - searchTerm: lastSearchTerm, - adThemeStyle: AdThemeStyle.fromTheme(theme), - ), - ), + HeadlinesSearchFetchRequested( + searchTerm: lastSearchTerm, + adThemeStyle: AdThemeStyle.fromTheme(theme), + ), + ), ), _ => const SizedBox.shrink(), }; diff --git a/lib/settings/view/feed_settings_page.dart b/lib/settings/view/feed_settings_page.dart index 3ab02387..4016e421 100644 --- a/lib/settings/view/feed_settings_page.dart +++ b/lib/settings/view/feed_settings_page.dart @@ -43,7 +43,7 @@ class FeedSettingsPage extends StatelessWidget { return BlocListener( listener: (context, settingsState) { if (settingsState.status == SettingsStatus.success) { - context.read().add(const AppUserAppSettingsRefreshed()); + context.read().add(const AppUserAppSettingsRefreshed()); } }, child: Scaffold( From 6584a4214e17b1a57053614232073afc74f6f482 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 18 Sep 2025 06:58:15 +0100 Subject: [PATCH 90/94] chore: set app environment to production - Change appEnvironment from AppEnvironment.demo to AppEnvironment.production in main.dart --- lib/main.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index 2d1e6a00..169d24b7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/app/services/spl import 'package:flutter_news_app_mobile_client_full_source_code/bootstrap.dart'; // Define the current application environment (production/development/demo). -const appEnvironment = AppEnvironment.demo; +const appEnvironment = AppEnvironment.production; void main() async { final appConfig = switch (appEnvironment) { From 071d5f2ed33e126e75a485dcf0bac9dcde703b53 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 18 Sep 2025 07:11:42 +0100 Subject: [PATCH 91/94] fix: Prevent "Loading Settings" for unauthenticated users Modified `lib/app/bloc/app_bloc.dart` to correctly initialize the `AppBloc`'s lifecycle status based on the presence of an initial user. This prevents the "Loading Settings" screen from appearing for unauthenticated users before they are directed to the authentication page, ensuring a more accurate and intuitive startup flow. --- lib/app/bloc/app_bloc.dart | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 09180a86..a23e9189 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -53,18 +53,15 @@ class AppBloc extends Bloc { _navigatorKey = navigatorKey, _logger = Logger('AppBloc'), super( - // Initial state of the app. The status is set to loadingUserData - // as the AppBloc will now handle fetching user-specific data. - // UserAppSettings and UserContentPreferences are initially null - // and will be fetched asynchronously. AppState( - status: AppLifeCycleStatus.loadingUserData, + status: initialUser == null + ? AppLifeCycleStatus.unauthenticated + : AppLifeCycleStatus.loadingUserData, selectedBottomNavigationIndex: 0, - remoteConfig: initialRemoteConfig, // Use the pre-fetched config - initialRemoteConfigError: - initialRemoteConfigError, // Store any initial config error + remoteConfig: initialRemoteConfig, + initialRemoteConfigError: initialRemoteConfigError, environment: environment, - user: initialUser, // Set initial user if available + user: initialUser, ), ) { // Register event handlers for various app-level events. @@ -262,20 +259,25 @@ class AppBloc extends Bloc { } // If we reach here, the app is not under maintenance or requires update. - // Proceed to load user-specific data. - emit(state.copyWith(status: AppLifeCycleStatus.loadingUserData)); - + // Now, handle user-specific data loading. final currentUser = event.initialUser; if (currentUser == null) { _logger.info( - '[AppBloc] No initial user. Transitioning to unauthenticated state.', + '[AppBloc] No initial user. Ensuring unauthenticated state.', ); - emit(state.copyWith(status: AppLifeCycleStatus.unauthenticated)); + // Ensure the state is unauthenticated if no user, and it wasn't already set by initial state. + if (state.status != AppLifeCycleStatus.unauthenticated) { + emit(state.copyWith(status: AppLifeCycleStatus.unauthenticated)); + } return; } - // User is present, proceed to fetch user-specific settings and preferences. + // If a user is present, and we are not already in loadingUserData state, + // transition to loadingUserData and fetch user-specific settings and preferences. + if (state.status != AppLifeCycleStatus.loadingUserData) { + emit(state.copyWith(status: AppLifeCycleStatus.loadingUserData)); + } await _fetchAndSetUserData(currentUser, emit); } From cad13fa22b52eca7d90dd44a6af0b39bb933f65c Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 18 Sep 2025 07:51:29 +0100 Subject: [PATCH 92/94] refactor(headline-details): use List.from instead of List.of - Replace List.of with List.from for creating a shallow copy of savedHeadlines list - This change improves code readability and follows the more common practice in the Flutter community --- lib/headline-details/view/headline_details_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/headline-details/view/headline_details_page.dart b/lib/headline-details/view/headline_details_page.dart index 3524ca57..838b206e 100644 --- a/lib/headline-details/view/headline_details_page.dart +++ b/lib/headline-details/view/headline_details_page.dart @@ -205,7 +205,7 @@ class _HeadlineDetailsPageState extends State { .where((h) => h.id != headline.id) .toList(); } else { - updatedSavedHeadlines = List.of(currentPreferences.savedHeadlines) + updatedSavedHeadlines = List.from(currentPreferences.savedHeadlines) ..add(headline); } From d9029746a32f4a5cac18ebcc16838e63a9f8bcf3 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 18 Sep 2025 07:52:00 +0100 Subject: [PATCH 93/94] refactor(app): improve user data loading logic and state handling - Update loading condition to check for `loadingUserData` status - Add comments to explain the loading state logic - Wrap LoadingStateWidget in a Builder to potentially improve widget tree structure --- lib/app/view/app.dart | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index b360060c..db7c0cec 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -413,10 +413,12 @@ class _AppViewState extends State<_AppView> { } // --- Loading User Data State --- - // If the app is not in a critical status but user settings or preferences - // are still null, display a loading screen. This ensures the main UI - // is only built when all necessary user-specific data is available. - if (state.settings == null || state.userContentPreferences == null) { + // --- Loading User Data State --- + // Display a loading screen ONLY if the app is actively trying to load + // user-specific data (settings or preferences) for an authenticated/anonymous user. + // If the status is unauthenticated, it means there's no user data to load, + // and the app should proceed to the router to show the authentication page. + if (state.status == AppLifeCycleStatus.loadingUserData) { return MaterialApp( debugShowCheckedModeBanner: false, theme: lightTheme( @@ -438,12 +440,16 @@ class _AppViewState extends State<_AppView> { ], supportedLocales: AppLocalizations.supportedLocales, locale: state.locale, - home: LoadingStateWidget( - icon: Icons.sync, - headline: AppLocalizations.of(context).settingsLoadingHeadline, - subheadline: AppLocalizations.of( - context, - ).settingsLoadingSubheadline, + home: Builder( + builder: (context) { + return LoadingStateWidget( + icon: Icons.sync, + headline: AppLocalizations.of(context).settingsLoadingHeadline, + subheadline: AppLocalizations.of( + context, + ).settingsLoadingSubheadline, + ); + }, ), ); } From 6f84e564fd49f5bec822c4e023a42e13dad51e58 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 18 Sep 2025 07:52:26 +0100 Subject: [PATCH 94/94] chore: switch app environment to demo - Change appEnvironment from production to demo in main.dart --- lib/main.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index 169d24b7..2d1e6a00 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/app/services/spl import 'package:flutter_news_app_mobile_client_full_source_code/bootstrap.dart'; // Define the current application environment (production/development/demo). -const appEnvironment = AppEnvironment.production; +const appEnvironment = AppEnvironment.demo; void main() async { final appConfig = switch (appEnvironment) {