diff --git a/lib/account/bloc/account_bloc.dart b/lib/account/bloc/account_bloc.dart deleted file mode 100644 index 6fc8cafb..00000000 --- a/lib/account/bloc/account_bloc.dart +++ /dev/null @@ -1,492 +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, - required DataRepository - userContentPreferencesRepository, - Logger? logger, - }) : _authenticationRepository = authenticationRepository, - _userContentPreferencesRepository = userContentPreferencesRepository, - _logger = logger ?? Logger('AccountBloc'), - super(const AccountState()) { - // Listen to user changes from AuthRepository - _userSubscription = _authenticationRepository.authStateChanges.listen(( - user, - ) { - 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 { - // 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', - ), - ), - ); - } - } - - Future _onAccountClearUserPreferences( - AccountClearUserPreferences event, - Emitter emit, - ) 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, - ), - ); - } 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', - ), - ), - ); - } - } - - /// 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(); - } -} diff --git a/lib/account/bloc/account_event.dart b/lib/account/bloc/account_event.dart deleted file mode 100644 index fe346acf..00000000 --- a/lib/account/bloc/account_event.dart +++ /dev/null @@ -1,66 +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 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}); - 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 b1c3a321..00000000 --- a/lib/account/bloc/account_state.dart +++ /dev/null @@ -1,37 +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.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]; -} 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..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 @@ -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,8 +150,28 @@ 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, + ), ); }, ), 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..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 @@ -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/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'; 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'; @@ -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,17 @@ 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,8 +87,18 @@ 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, + ), ); }, ), 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..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 @@ -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,8 +80,28 @@ 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, + ), ); }, ), 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..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 @@ -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/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'; 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'; @@ -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,17 @@ 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,8 +84,18 @@ 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, + ), ); }, ), 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..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 @@ -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,8 +149,28 @@ 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, + ), ); }, ), 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..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 @@ -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/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'; 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'; @@ -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,17 @@ 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,8 +92,18 @@ 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, + ), ); }, ), diff --git a/lib/account/view/saved_headlines_page.dart b/lib/account/view/saved_headlines_page.dart index 12857fe4..73b70176 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,16 @@ 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,19 +93,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, + ), ); }, ); 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); diff --git a/lib/ads/widgets/feed_ad_loader_widget.dart b/lib/ads/widgets/feed_ad_loader_widget.dart index d4d28bf2..5caad59a 100644 --- a/lib/ads/widgets/feed_ad_loader_widget.dart +++ b/lib/ads/widgets/feed_ad_loader_widget.dart @@ -207,8 +207,6 @@ class _FeedAdLoaderWidgetState extends State { final headlineImageStyle = context .read() .state - .settings - .feedPreferences .headlineImageStyle; // Call AdService.getFeedAd with the full AdConfig and adType from the placeholder. @@ -279,12 +277,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. 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. diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 0f56b65f..a23e9189 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -16,74 +16,64 @@ 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. It acts as the single source of truth for global +/// application state. +/// {@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, + required DataRepository + userContentPreferencesRepository, required DataRepository appConfigRepository, required DataRepository userRepository, required local_config.AppEnvironment environment, required GlobalKey navigatorKey, + required RemoteConfig? initialRemoteConfig, + required HttpException? initialRemoteConfigError, this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, }) : _authenticationRepository = authenticationRepository, _userAppSettingsRepository = userAppSettingsRepository, + _userContentPreferencesRepository = userContentPreferencesRepository, _appConfigRepository = appConfigRepository, _userRepository = userRepository, _environment = environment, _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. AppState( status: initialUser == null ? AppLifeCycleStatus.unauthenticated - : AppLifeCycleStatus.configFetching, - settings: UserAppSettings( - id: 'default', - 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, - ), - ), + : AppLifeCycleStatus.loadingUserData, selectedBottomNavigationIndex: 0, - remoteConfig: null, + remoteConfig: initialRemoteConfig, + initialRemoteConfigError: initialRemoteConfigError, 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, ), ) { // Register event handlers for various app-level events. + on(_onAppStarted); on(_onAppUserChanged); - on(_onAppSettingsRefreshed); - on(_onAppConfigFetchRequested); + on(_onUserAppSettingsRefreshed); + on(_onUserContentPreferencesRefreshed); + on(_onAppSettingsChanged); + on(_onAppPeriodicConfigFetchRequested); on(_onAppUserFeedDecoratorShown); + on(_onAppUserContentPreferencesChanged); on(_onLogoutRequested); - on(_onThemeModeChanged); - on(_onFlexSchemeChanged); - on(_onFontFamilyChanged); - on(_onAppTextScaleFactorChanged); - on(_onAppFontWeightChanged); // Subscribe to the authentication repository's authStateChanges stream. // This stream is the single source of truth for the user's auth state @@ -95,6 +85,8 @@ class AppBloc extends Bloc { final AuthRepository _authenticationRepository; final DataRepository _userAppSettingsRepository; + final DataRepository + _userContentPreferencesRepository; final DataRepository _appConfigRepository; final DataRepository _userRepository; final local_config.AppEnvironment _environment; @@ -108,71 +100,231 @@ 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. + /// Fetches [UserAppSettings] and [UserContentPreferences] for the given + /// [user] and updates the [AppState]. /// - /// 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 `remoteConfig` and `userAppSettings` - /// concurrently. - /// 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. - /// 6. If any fetch fails, it will emit a `configFetchFailed` state, - /// allowing the user to retry. - Future _onAppUserChanged( - AppUserChanged event, + /// 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 { + 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 { - final oldUser = state.user; + _logger.info('[AppBloc] Fetching user settings 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}', + ); + 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()), + ), + ); + } + } - // 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) { + /// 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] AppUserChanged triggered, but user ID is the same. ' - 'Skipping reload.', + '[AppBloc] UserContentPreferences fetched successfully for user: ${user.id}', + ); + emit(state.copyWith(userContentPreferences: userContentPreferences)); + } on HttpException catch (e) { + _logger.severe( + '[AppBloc] Failed to fetch user content 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 content 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 { + _logger.info('[AppBloc] AppStarted event received. Starting data load.'); + + // If there was a critical error during bootstrap (e.g., RemoteConfig fetch failed), + // 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, - remoteConfig: null, - clearAppConfig: true, // Explicitly clear remoteConfig + 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.', - ); + // 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; + } - // Immediately emit the new user and set status to configFetching. - // This ensures the UI shows a loading state while we fetch data. - emit( - state.copyWith(user: newUser, status: AppLifeCycleStatus.configFetching), - ); + 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; + } + + // If we reach here, the app is not under maintenance or requires update. + // Now, handle user-specific data loading. + final currentUser = event.initialUser; + + if (currentUser == null) { + _logger.info( + '[AppBloc] No initial user. Ensuring unauthenticated state.', + ); + // 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; + } + + // 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); + } + + /// 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)); + + // 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) { + demoDataInitializerService != null && + newUser != null) { try { _logger.info('Demo mode: Initializing data for user ${newUser.id}.'); await demoDataInitializerService!.initializeUserSpecificData(newUser); @@ -187,6 +339,7 @@ class AppBloc extends Bloc { // 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 ' @@ -201,282 +354,60 @@ class AppBloc extends Bloc { _logger.info('Demo mode: Data migration completed for ${newUser.id}.'); } } - - // --- 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; - - _logger.info( - '[AppBloc] RemoteConfig and UserAppSettings fetched successfully for ' - 'user: ${newUser.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); - - // --- CRITICAL STATUS EVALUATION --- - if (remoteConfig.appStatus.isUnderMaintenance) { - emit( - state.copyWith( - status: AppLifeCycleStatus.underMaintenance, - remoteConfig: remoteConfig, - 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, - 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 - ? AppLifeCycleStatus.authenticated - : AppLifeCycleStatus.anonymous; - - emit( - state.copyWith( - status: finalStatus, - remoteConfig: remoteConfig, - settings: userAppSettings, - themeMode: newThemeMode, - flexScheme: newFlexScheme, - fontFamily: newFontFamily, - appTextScaleFactor: newAppTextScaleFactor, - locale: newLocale, - ), - ); - } on HttpException catch (e) { - _logger.severe( - '[AppBloc] Failed to fetch initial data (HttpException) for user ' - '${newUser.id}: ${e.runtimeType} - ${e.message}', - ); - emit(state.copyWith(status: AppLifeCycleStatus.configFetchFailed)); - } catch (e, s) { - _logger.severe( - '[AppBloc] Unexpected error during initial data fetch for user ' - '${newUser.id}.', - e, - s, - ); - emit(state.copyWith(status: AppLifeCycleStatus.configFetchFailed)); - } } /// 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.'); - return; - } - - try { - final userAppSettings = await _userAppSettingsRepository.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, - ); - 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, - locale: newLocale, - ), - ); - } on HttpException catch (e) { - _logger.severe( - 'Error loading user app settings in AppBloc (HttpException): $e', + _logger.info( + '[AppBloc] Skipping AppUserAppSettingsRefreshed: User is null.', ); - emit(state.copyWith(settings: state.settings)); - } catch (e, s) { - _logger.severe('Error loading user app settings in AppBloc.', e, s); - emit(state.copyWith(settings: state.settings)); + return; } - } - - /// 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 { - 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, - ); - } + await _fetchAndSetUserSettings(state.user!, emit); } - /// Handles changes to the application's accent color scheme. - Future _onFlexSchemeChanged( - AppFlexSchemeChanged event, + /// Handles refreshing/loading user content preferences. + Future _onUserContentPreferencesRefreshed( + AppUserContentPreferencesRefreshed event, Emitter emit, ) async { - 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, - ); + if (state.user == null) { _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, + '[AppBloc] Skipping AppUserContentPreferencesRefreshed: User is null.', ); + return; } + + await _fetchAndSetUserContentPreferences(state.user!, emit); } - /// 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 { - 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, + // Ensure settings are loaded and a user is available before attempting to update. + if (state.user == null || state.settings == null) { + _logger.warning( + '[AppBloc] Skipping AppSettingsChanged: User or UserAppSettings not loaded.', ); + return; } - } - /// Handles changes to the application's text scale factor. - Future _onAppTextScaleFactorChanged( - AppTextScaleFactorChanged event, - Emitter emit, - ) async { - final updatedSettings = state.settings.copyWith( - displaySettings: state.settings.displaySettings.copyWith( - textScaleFactor: event.appTextScaleFactor, - ), - ); - emit( - state.copyWith( - settings: updatedSettings, - appTextScaleFactor: event.appTextScaleFactor, - ), - ); + final updatedSettings = event.settings; + + emit(state.copyWith(settings: updatedSettings)); + try { await _userAppSettingsRepository.update( id: updatedSettings.id, @@ -484,83 +415,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 { - 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, + 'Failed to persist UserAppSettings for user ${updatedSettings.id} (HttpException): $e', ); - _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]. - 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]. - 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. - 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) { - return factor; + /// Handles user logout request. + void _onLogoutRequested(AppLogoutRequested event, Emitter emit) { + unawaited(_authenticationRepository.signOut()); } @override @@ -569,31 +445,34 @@ class AppBloc extends Bloc { return super.close(); } - /// Handles fetching the remote application configuration. - Future _onAppConfigFetchRequested( - AppConfigFetchRequested event, + /// 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 _onAppPeriodicConfigFetchRequested( + AppPeriodicConfigFetchRequested event, Emitter emit, ) async { if (state.user == null) { _logger.info('[AppBloc] User is null. Skipping AppConfig fetch.'); - if (state.remoteConfig != null || - state.status == AppLifeCycleStatus.configFetching) { - emit( - state.copyWith( - remoteConfig: null, - clearAppConfig: true, - status: AppLifeCycleStatus.unauthenticated, - ), - ); - } + emit( + state.copyWith( + remoteConfig: null, + clearAppConfig: true, + status: AppLifeCycleStatus.unauthenticated, + initialRemoteConfigError: null, + ), + ); return; } + // Only show critical error if it's not a background check. if (!event.isBackgroundCheck) { _logger.info( - '[AppBloc] Initial config fetch. Setting status to configFetching.', + '[AppBloc] Initial config fetch. Setting status to loadingUserData if failed.', ); - emit(state.copyWith(status: AppLifeCycleStatus.configFetching)); + emit(state.copyWith(status: AppLifeCycleStatus.loadingUserData)); } else { _logger.info('[AppBloc] Background config fetch. Proceeding silently.'); } @@ -609,6 +488,7 @@ class AppBloc extends Bloc { state.copyWith( status: AppLifeCycleStatus.underMaintenance, remoteConfig: remoteConfig, + initialRemoteConfigError: null, ), ); return; @@ -620,6 +500,7 @@ class AppBloc extends Bloc { state.copyWith( status: AppLifeCycleStatus.updateRequired, remoteConfig: remoteConfig, + initialRemoteConfigError: null, ), ); return; @@ -628,13 +509,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( @@ -643,7 +535,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()), + ), + ); } } } @@ -658,7 +555,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 +564,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, @@ -696,4 +593,59 @@ 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), + ); + } + } } diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart index 9f5b0403..e48dc4bd 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(); @@ -7,10 +10,29 @@ abstract class AppEvent extends Equatable { List get props => []; } +/// 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}); + + /// 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). +/// +/// 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 @@ -18,13 +40,43 @@ class AppUserChanged extends AppEvent { } /// Dispatched to request a refresh of the user's application settings. -class AppSettingsRefreshed extends AppEvent { - const AppSettingsRefreshed(); +/// +/// This event is typically used when external changes might have occurred +/// or when a manual refresh of settings is desired. +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 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. /// @@ -38,60 +90,47 @@ 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 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}); -/// Dispatched when the font family changes. -class AppFontFamilyChanged extends AppEvent { - const AppFontFamilyChanged(this.fontFamily); - final String? fontFamily; - @override - List get props => [fontFamily]; -} + /// The updated [UserContentPreferences] object. + final UserContentPreferences preferences; -/// 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]; + 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 +/// 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]; } diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index 04f1814f..2d1ff374 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -6,9 +6,8 @@ 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 application is currently loading user-specific data (settings, preferences). + loadingUserData, /// The user is not authenticated. unauthenticated, @@ -19,12 +18,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, @@ -37,56 +33,113 @@ 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} const AppState({ required this.status, - required this.settings, required this.environment, this.user, this.remoteConfig, - this.themeMode = ThemeMode.system, - this.flexScheme = FlexScheme.blue, - this.fontFamily, - this.appTextScaleFactor = AppTextScaleFactor.medium, + this.initialRemoteConfigError, + this.initialUserPreferencesError, + this.userContentPreferences, + this.settings, 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. - final UserAppSettings settings; + /// 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; - /// The current theme mode (light, dark, or system). - final ThemeMode themeMode; - - /// The current FlexColorScheme scheme for accent colors. - final FlexScheme flexScheme; + /// An error that occurred during the initial remote config fetch. + /// If not null, indicates a critical issue preventing app startup. + final HttpException? initialRemoteConfigError; - /// The currently selected font family. - final String? fontFamily; + /// An error that occurred during the initial user preferences fetch. + /// If not null, indicates a critical issue preventing app startup. + final HttpException? initialUserPreferencesError; - /// The current text scale factor. - final AppTextScaleFactor appTextScaleFactor; + /// 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 currently selected index for bottom navigation. final int selectedBottomNavigationIndex; - /// The current application environment. + /// The current application environment (e.g., demo, development, production). final local_config.AppEnvironment environment; - /// The currently selected locale for localization. - final Locale? locale; + /// 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 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 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 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 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 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 { + return Locale(settings?.language.code ?? 'en'); + } @override List get props => [ @@ -94,13 +147,11 @@ class AppState extends Equatable { user, settings, remoteConfig, - themeMode, - flexScheme, - fontFamily, - appTextScaleFactor, + initialRemoteConfigError, + initialUserPreferencesError, + userContentPreferences, selectedBottomNavigationIndex, environment, - locale, ]; /// Creates a copy of this [AppState] with the given fields replaced with @@ -111,27 +162,26 @@ class AppState extends Equatable { UserAppSettings? settings, RemoteConfig? remoteConfig, bool clearAppConfig = false, - ThemeMode? themeMode, - FlexScheme? flexScheme, - String? fontFamily, - AppTextScaleFactor? appTextScaleFactor, + HttpException? initialRemoteConfigError, + HttpException? initialUserPreferencesError, + UserContentPreferences? userContentPreferences, int? selectedBottomNavigationIndex, local_config.AppEnvironment? environment, - Locale? locale, }) { return AppState( status: status ?? this.status, user: user ?? this.user, settings: settings ?? this.settings, remoteConfig: clearAppConfig ? null : remoteConfig ?? this.remoteConfig, - themeMode: themeMode ?? this.themeMode, - flexScheme: flexScheme ?? this.flexScheme, - fontFamily: fontFamily ?? this.fontFamily, - appTextScaleFactor: appTextScaleFactor ?? this.appTextScaleFactor, + initialRemoteConfigError: + initialRemoteConfigError ?? this.initialRemoteConfigError, + initialUserPreferencesError: + initialUserPreferencesError ?? this.initialUserPreferencesError, + userContentPreferences: + userContentPreferences ?? this.userContentPreferences, selectedBottomNavigationIndex: selectedBottomNavigationIndex ?? this.selectedBottomNavigationIndex, environment: environment ?? this.environment, - locale: locale ?? this.locale, ); } } 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), ); } } diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 543b1719..db7c0cec 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,10 +47,12 @@ class App extends StatelessWidget { required DataRepository localAdRepository, required GlobalKey navigatorKey, required InlineAdCacheService inlineAdCacheService, + required RemoteConfig? initialRemoteConfig, + required HttpException? initialRemoteConfigError, + super.key, this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, - super.key, }) : _authenticationRepository = authenticationRepository, _headlinesRepository = headlinesRepository, _topicsRepository = topicsRepository, @@ -56,7 +67,9 @@ class App extends StatelessWidget { _adService = adService, _localAdRepository = localAdRepository, _navigatorKey = navigatorKey, - _inlineAdCacheService = inlineAdCacheService; + _inlineAdCacheService = inlineAdCacheService, + _initialRemoteConfig = initialRemoteConfig, + _initialRemoteConfigError = initialRemoteConfigError; final AuthRepository _authenticationRepository; final DataRepository _headlinesRepository; @@ -78,6 +91,13 @@ class App extends StatelessWidget { 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; + + /// Any error that occurred during the initial remote config fetch. + final HttpException? _initialRemoteConfigError; + @override Widget build(BuildContext context) { return MultiRepositoryProvider( @@ -99,27 +119,37 @@ class App extends StatelessWidget { child: MultiBlocProvider( providers: [ BlocProvider( - create: (context) => AppBloc( - authenticationRepository: context.read(), - userAppSettingsRepository: context - .read>(), - appConfigRepository: context.read>(), - userRepository: context.read>(), - environment: _environment, - demoDataMigrationService: demoDataMigrationService, - demoDataInitializerService: demoDataInitializerService, - initialUser: initialUser, - navigatorKey: _navigatorKey, // Pass navigatorKey to AppBloc - ), + 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( 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 +225,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), @@ -259,6 +292,59 @@ 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.criticalError) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: lightTheme( + scheme: FlexScheme.material, + appTextScaleFactor: state.appTextScaleFactor, + appFontWeight: state.appFontWeight, + fontFamily: state.fontFamily, + ), + darkTheme: darkTheme( + scheme: FlexScheme.material, + appTextScaleFactor: state.appTextScaleFactor, + appFontWeight: state.appFontWeight, + fontFamily: state.fontFamily, + ), + themeMode: state.themeMode, + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + ...UiKitLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: state.locale, + home: CriticalErrorPage( + exception: + state.initialRemoteConfigError ?? + state.initialUserPreferencesError ?? + 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 AppPeriodicConfigFetchRequested( + 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), + ); + } + }, + ), + ); + } + if (state.status == AppLifeCycleStatus.underMaintenance) { // The app is in maintenance mode. Show the MaintenancePage. // @@ -278,15 +364,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 [ @@ -305,15 +391,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 [ @@ -326,24 +412,26 @@ class _AppViewState extends State<_AppView> { ); } - 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. + // --- Loading User Data State --- + // --- 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( 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 [ @@ -352,25 +440,37 @@ class _AppViewState extends State<_AppView> { ], supportedLocales: AppLocalizations.supportedLocales, locale: state.locale, - home: const StatusPage(), + home: Builder( + builder: (context) { + return 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( @@ -378,17 +478,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, diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 3c21156d..cf8869bd 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -51,12 +51,71 @@ 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. + 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 + // 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 +123,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 +132,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 +191,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(), @@ -177,13 +214,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 @@ -207,10 +237,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(), @@ -228,12 +264,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 +318,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 +376,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 +408,6 @@ Future bootstrap( final userAppSettingsRepository = DataRepository( dataClient: userAppSettingsClient, ); - final remoteConfigRepository = DataRepository( - dataClient: remoteConfigClient, - ); final userRepository = DataRepository(dataClient: userClient); // Conditionally instantiate DemoDataMigrationService @@ -435,5 +448,8 @@ Future bootstrap( initialUser: initialUser, localAdRepository: localAdRepository, navigatorKey: navigatorKey, // Pass the navigatorKey to App + initialRemoteConfig: initialRemoteConfig, // Pass the initialRemoteConfig + 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 8357fc9c..e76940c7 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, @@ -130,14 +127,13 @@ class EntityDetailsBloc extends Bloc { feedItems: headlineResponse.items, user: currentUser, adConfig: remoteConfig.adConfig, - imageStyle: - _appBloc.state.settings.feedPreferences.headlineImageStyle, + 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 +176,78 @@ 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; + final 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 +297,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. @@ -281,39 +333,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(); - } } 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]; -} diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index 44ec0f2c..a7841380 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -326,10 +326,8 @@ class _EntityDetailsViewState extends State { if (item is Headline) { final imageStyle = context - .watch() + .read() .state - .settings - .feedPreferences .headlineImageStyle; Widget tile; switch (imageStyle) { diff --git a/lib/headline-details/view/headline_details_page.dart b/lib/headline-details/view/headline_details_page.dart index 9fa71f2c..838b206e 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.from(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) { 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 5eb1ea63..00000000 --- a/lib/headlines-feed/bloc/countries_filter_bloc.dart +++ /dev/null @@ -1,144 +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/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'; -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, - 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. - /// - /// 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)); - } - } - - /// 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()), - ), - ); - } - } -} 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 46d329dc..00000000 --- a/lib/headlines-feed/bloc/countries_filter_event.dart +++ /dev/null @@ -1,35 +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]; -} - -/// {@template countries_filter_apply_followed_requested} -/// Event triggered to request applying the user's followed countries as filters. -/// {@endtemplate} -final class CountriesFilterApplyFollowedRequested - extends CountriesFilterEvent {} 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 74143648..00000000 --- a/lib/headlines-feed/bloc/countries_filter_state.dart +++ /dev/null @@ -1,100 +0,0 @@ -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. - 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, - this.followedCountriesStatus = CountriesFilterStatus.initial, - this.followedCountries = const [], - }); - - /// 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; - - /// 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, - List? countries, - 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, - 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, - followedCountriesStatus: - followedCountriesStatus ?? this.followedCountriesStatus, - followedCountries: followedCountries ?? this.followedCountries, - ); - } - - @override - List get props => [ - status, - countries, - hasMore, - cursor, - error, - followedCountriesStatus, - followedCountries, - ]; -} 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, ); 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..205b1a19 --- /dev/null +++ b/lib/headlines-feed/bloc/headlines_filter_bloc.dart @@ -0,0 +1,213 @@ +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, + ), + ); + } +} 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(); +} 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, + ]; +} 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 d0b6efe7..00000000 --- a/lib/headlines-feed/bloc/sources_filter_bloc.dart +++ /dev/null @@ -1,255 +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'; -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'; - -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); - on(_onCountryCapsuleToggled); - on(_onAllSourceTypesCapsuleToggled); - on(_onSourceTypeCapsuleToggled); - on(_onSourceCheckboxToggled); - on( - _onSourcesFilterApplyFollowedRequested, - ); - } - - final DataRepository _sourcesRepository; - final DataRepository _countriesRepository; - final DataRepository - _userContentPreferencesRepository; - final AppBloc _appBloc; - - 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)); - } - - /// 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, - 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 3105fd82..00000000 --- a/lib/headlines-feed/bloc/sources_filter_event.dart +++ /dev/null @@ -1,68 +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]; -} - -/// {@template sources_filter_apply_followed_requested} -/// Event triggered to request applying the user's followed sources as filters. -/// {@endtemplate} -final class SourcesFilterApplyFollowedRequested extends SourcesFilterEvent {} 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 a3d52146..00000000 --- a/lib/headlines-feed/bloc/sources_filter_state.dart +++ /dev/null @@ -1,86 +0,0 @@ -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 { - 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, - this.followedSourcesStatus = SourceFilterDataLoadingStatus.initial, - this.followedSources = const [], - }); - - 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; - - /// 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, - List? availableSourceTypes, - Set? selectedSourceTypes, - List? allAvailableSources, - List? displayableSources, - Set? finallySelectedSourceIds, - SourceFilterDataLoadingStatus? dataLoadingStatus, - HttpException? error, - SourceFilterDataLoadingStatus? followedSourcesStatus, - List? followedSources, - bool clearErrorMessage = false, - bool clearFollowedSourcesError = 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, - followedSourcesStatus: - followedSourcesStatus ?? this.followedSourcesStatus, - followedSources: followedSources ?? this.followedSources, - ); - } - - @override - List get props => [ - countriesWithActiveSources, - selectedCountryIsoCodes, - availableSourceTypes, - selectedSourceTypes, - allAvailableSources, - displayableSources, - finallySelectedSourceIds, - dataLoadingStatus, - error, - followedSourcesStatus, - followedSources, - ]; -} 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 e61c77d1..00000000 --- a/lib/headlines-feed/bloc/topics_filter_bloc.dart +++ /dev/null @@ -1,171 +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/app/bloc/app_bloc.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, - required DataRepository - userContentPreferencesRepository, - required AppBloc appBloc, - }) : _topicsRepository = topicsRepository, - _userContentPreferencesRepository = userContentPreferencesRepository, - _appBloc = appBloc, - super(const TopicsFilterState()) { - on( - _onTopicsFilterRequested, - transformer: restartable(), - ); - on( - _onTopicsFilterLoadMoreRequested, - transformer: droppable(), - ); - on( - _onTopicsFilterApplyFollowedRequested, - transformer: restartable(), - ); - } - - final DataRepository _topicsRepository; - final DataRepository - _userContentPreferencesRepository; - final AppBloc _appBloc; - - /// 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) { - // Optionally add logic here for forced refresh if needed - 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)); - } - } - - /// 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()), - ), - ); - } - } -} 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 bc2ce98c..00000000 --- a/lib/headlines-feed/bloc/topics_filter_event.dart +++ /dev/null @@ -1,27 +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 {} - -/// {@template topics_filter_apply_followed_requested} -/// Event triggered to request applying the user's followed topics as filters. -/// {@endtemplate} -final class TopicsFilterApplyFollowedRequested 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 1fd3508a..00000000 --- a/lib/headlines-feed/bloc/topics_filter_state.dart +++ /dev/null @@ -1,97 +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, - this.followedTopicsStatus = TopicsFilterStatus.initial, - this.followedTopics = const [], - }); - - /// 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; - - /// 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, - List? topics, - 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, - 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, - followedTopicsStatus: followedTopicsStatus ?? this.followedTopicsStatus, - followedTopics: followedTopics ?? this.followedTopics, - ); - } - - @override - List get props => [ - status, - topics, - hasMore, - cursor, - error, - followedTopicsStatus, - followedTopics, - ]; -} diff --git a/lib/headlines-feed/view/country_filter_page.dart b/lib/headlines-feed/view/country_filter_page.dart index 7f889e52..ce7d8f0e 100644 --- a/lib/headlines-feed/view/country_filter_page.dart +++ b/lib/headlines-feed/view/country_filter_page.dart @@ -1,87 +1,24 @@ 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/headlines-feed/bloc/countries_filter_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/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; - 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). - 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 ?? []); - - // 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)); - }); - } - - @override - void dispose() { - super.dispose(); - } - @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; @@ -91,20 +28,23 @@ 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, state) { - // Determine if the "Apply My Followed" icon should be filled - final followedCountriesSet = state.followedCountries.toSet(); + BlocBuilder( + builder: (context, filterState) { + final appState = context.watch().state; + final followedCountries = + appState.userContentPreferences?.followedCountries ?? []; + + // Determine if the current selection matches the followed countries final isFollowedFilterActive = - followedCountriesSet.isNotEmpty && - _pageSelectedCountries.length == - followedCountriesSet.length && - _pageSelectedCountries.containsAll(followedCountriesSet); + followedCountries.isNotEmpty && + filterState.selectedCountries.length == + followedCountries.length && + filterState.selectedCountries.containsAll(followedCountries); return IconButton( icon: isFollowedFilterActive @@ -114,197 +54,141 @@ class _CountryFilterPageState extends State { ? 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: () { + if (followedCountries.isEmpty) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(l10n.noFollowedItemsForFilterSnackbar), + 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(); }, ), ], ), - // 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, filterState) { + // Determine overall loading status for the main list + final isLoadingMainList = + filterState.status == HeadlinesFilterStatus.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 failure state (show error and retry button) + if (filterState.status == HeadlinesFilterStatus.failure && + filterState.allCountries.isEmpty) { + return FailureStateWidget( + 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) { - return InitialStateWidget( - icon: Icons.flag_circle_outlined, - headline: l10n.countryFilterEmptyHeadline, - subheadline: l10n.countryFilterEmptySubheadline, - ); - } + // Handle empty state (after successful load but no countries found) + if (filterState.allCountries.isEmpty) { + return InitialStateWidget( + icon: Icons.flag_circle_outlined, + headline: l10n.countryFilterEmptyHeadline, + subheadline: l10n.countryFilterEmptySubheadline, + ); + } - // 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 loaded state (success) + return ListView.builder( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.paddingSmall, + ).copyWith(bottom: AppSpacing.xxl), + itemCount: filterState.allCountries.length, + itemBuilder: (context, index) { + final country = filterState.allCountries[index]; + final isSelected = filterState.selectedCountries.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, - ), - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Center( - child: CircularProgressIndicator( - strokeWidth: 2, - value: - loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ); - }, - ), - ), + 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) { + if (value != null) { + context.read().add( + FilterCountryToggled(country: country, isSelected: value), + ); + } + }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + ), + ); + }, + ); + }, ), ); } diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index 30b5e29a..dd43efbf 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'; @@ -148,12 +147,7 @@ class _HeadlinesFeedPageState extends State { tooltip: l10n.headlinesFeedFilterTooltip, onPressed: () { // Navigate to the filter page route - final headlinesFeedBloc = context - .read(); - context.goNamed( - Routes.feedFilterName, - extra: headlinesFeedBloc, - ); + context.goNamed(Routes.feedFilterName); }, ), if (isFilterApplied) @@ -305,8 +299,6 @@ class _HeadlinesFeedPageState extends State { final imageStyle = context .watch() .state - .settings - .feedPreferences .headlineImageStyle; Widget tile; switch (imageStyle) { @@ -342,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,13 +358,13 @@ 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) @@ -384,26 +378,53 @@ class _HeadlinesFeedPageState extends State { 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, + final isCurrentlyFollowing = followedTopicIds + .contains(toggledItem.id); + final newFollowedTopics = List.from( + followedTopics, ); - context.read().add( - AccountFollowTopicToggled(topic: toggledItem), + 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, + final isCurrentlyFollowing = followedSourceIds + .contains(toggledItem.id); + final newFollowedSources = List.from( + followedSources, ); - context.read().add( - AccountFollowSourceToggled(source: toggledItem), + 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( diff --git a/lib/headlines-feed/view/headlines_filter_page.dart b/lib/headlines-feed/view/headlines_filter_page.dart index 3d117358..9cbac191 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,191 +22,49 @@ 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; - - // New state variables for the "Apply my followed items" feature - bool _useFollowedFilters = false; - bool _isLoadingFollowedFilters = false; - String? _loadFollowedFiltersError; - UserContentPreferences? _currentUserPreferences; - - @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 ?? []); - - _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, - ); + Widget build(BuildContext context) { + // Access the HeadlinesFeedBloc to get the current filter state for initialization. + final headlinesFeedBloc = context.read(); + final currentFilter = headlinesFeedBloc.state.filter; - // 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), + 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, ), - ); - } - } on HttpException catch (e) { - setState(() { - _isLoadingFollowedFilters = false; - _useFollowedFilters = false; - _loadFollowedFiltersError = e.message; - }); - } catch (e) { - setState(() { - _isLoadingFollowedFilters = false; - _useFollowedFilters = false; - _loadFollowedFiltersError = AppLocalizationsX( - context, - ).l10n.unknownError; - }); - } + ), + 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; @@ -225,14 +81,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, ); @@ -243,11 +95,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 and loading. - // This will disable the individual filter tiles. - final isFollowedFilterActiveOrLoading = - _useFollowedFilters || _isLoadingFollowedFilters; - return Scaffold( appBar: AppBar( leading: IconButton( @@ -262,59 +109,76 @@ class _HeadlinesFilterPageState extends State { icon: const Icon(Icons.refresh), tooltip: l10n.headlinesFeedFilterResetButton, onPressed: () { - context.read().add( - HeadlinesFeedFiltersCleared( - adThemeStyle: AdThemeStyle.fromTheme(Theme.of(context)), - ), + context.read().add( + const FilterSelectionsCleared(), ); - // 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, filterState) { + final appState = context.watch().state; + 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: filterState.isUsingFollowedItems + ? const Icon(Icons.favorite) + : const Icon(Icons.favorite_border), + color: filterState.isUsingFollowedItems + ? theme.colorScheme.primary + : null, + tooltip: l10n.headlinesFeedFilterApplyFollowedLabel, + onPressed: hasFollowedItems + ? () { + context.read().add( + FollowedItemsFilterToggled( + isUsingFollowedItems: + !filterState.isUsingFollowedItems, + ), + ); } - }); - }, + : () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + l10n.noFollowedItemsForFilterSnackbar, + ), + duration: const Duration(seconds: 3), + ), + ); + }, + ); + }, ), // Apply Filters Button IconButton( 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( @@ -327,74 +191,69 @@ 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), + 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, ), - const SizedBox(width: AppSpacing.md), - Text(l10n.headlinesFeedLoadingHeadline), - ], + ); + }, + ); + } + + 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, ), - ), - if (_loadFollowedFiltersError != null) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingLarge, - vertical: AppSpacing.sm, + _buildFilterTile( + context: context, + title: l10n.headlinesFeedFilterSourceLabel, + enabled: !isFollowedFilterActive, + selectedCount: filterState.selectedSources.length, + routeName: Routes.feedFilterSourcesName, ), - child: Text( - _loadFollowedFiltersError!, - style: TextStyle(color: theme.colorScheme.error), + _buildFilterTile( + context: context, + title: l10n.headlinesFeedFilterEventCountryLabel, + enabled: !isFollowedFilterActive, + selectedCount: filterState.selectedCountries.length, + routeName: Routes.feedFilterEventCountriesName, ), - ), - const Divider(), - _buildFilterTile( - context: context, - title: l10n.headlinesFeedFilterTopicLabel, - enabled: !isFollowedFilterActiveOrLoading, - selectedCount: _tempSelectedTopics.length, - routeName: Routes.feedFilterTopicsName, - currentSelectionData: _tempSelectedTopics, - onResult: (result) { - setState(() => _tempSelectedTopics = result); - }, - ), - _buildFilterTile( - context: context, - title: l10n.headlinesFeedFilterSourceLabel, - enabled: !isFollowedFilterActiveOrLoading, - selectedCount: _tempSelectedSources.length, - routeName: Routes.feedFilterSourcesName, - currentSelectionData: _tempSelectedSources, - onResult: (result) { - setState(() => _tempSelectedSources = result); - }, - ), - _buildFilterTile( - context: context, - title: l10n.headlinesFeedFilterEventCountryLabel, - enabled: !isFollowedFilterActiveOrLoading, - selectedCount: _tempSelectedEventCountries.length, - routeName: Routes.feedFilterEventCountriesName, - currentSelectionData: _tempSelectedEventCountries, - onResult: (result) { - setState(() => _tempSelectedEventCountries = result); - }, - ), - ], + ], + ); + }, ), ); } diff --git a/lib/headlines-feed/view/source_filter_page.dart b/lib/headlines-feed/view/source_filter_page.dart index 460c3661..2c108836 100644 --- a/lib/headlines-feed/view/source_filter_page.dart +++ b/lib/headlines-feed/view/source_filter_page.dart @@ -1,49 +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>(), - userContentPreferencesRepository: context - .read>(), - appBloc: context.read(), - )..add( - LoadSourceFilterData( - initialSelectedSources: initialSelectedSources, - ), - ), - child: const _SourceFilterView(), - ); + return const _SourceFilterView(); } } @@ -55,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( @@ -65,17 +44,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 + BlocBuilder( + builder: (context, filterState) { + final appState = context.watch().state; + final followedSources = + appState.userContentPreferences?.followedSources ?? []; + + // Determine if the current selection matches the followed sources final isFollowedFilterActive = - state.followedSources.isNotEmpty && - state.finallySelectedSourceIds.length == - state.followedSources.length && - state.followedSources.every( - (source) => - state.finallySelectedSourceIds.contains(source.id), - ); + followedSources.isNotEmpty && + filterState.selectedSources.length == + followedSources.length && + filterState.selectedSources.containsAll(followedSources); return IconButton( icon: isFollowedFilterActive @@ -85,155 +65,140 @@ 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: () { + if (followedSources.isEmpty) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(l10n.noFollowedItemsForFilterSnackbar), + 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: 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, filterState) { + final isLoadingMainList = + filterState.status == HeadlinesFilterStatus.loading; + + if (isLoadingMainList) { + return LoadingStateWidget( + icon: Icons.source_outlined, + headline: l10n.sourceFilterLoadingHeadline, + subheadline: l10n.sourceFilterLoadingSubheadline, + ); + } + if (filterState.status == HeadlinesFilterStatus.failure && + filterState.allSources.isEmpty) { + return FailureStateWidget( + 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, ), ); - } - } else if (state.followedSourcesStatus == - SourceFilterDataLoadingStatus.failure) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text(state.error?.message ?? l10n.unknownError), - duration: const Duration(seconds: 3), - ), - ); + }, + ); } - }, - 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(), - ); - }, - ); - } + // 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(); - 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), - ), - ], + 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, ), - // 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, - ), - ), - ], - ), - ), - ), - ), - ], + ), ); - }, - ), + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCountryCapsules(context, filterState, l10n, textTheme), + const SizedBox(height: AppSpacing.md), + _buildSourceTypeCapsules(context, filterState, 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, + filterState, + l10n, + textTheme, + displayableSources, + ), + ), + ], + ); + }, ), ); } Widget _buildCountryCapsules( BuildContext context, - SourcesFilterState state, + HeadlinesFilterState filterState, AppLocalizations l10n, TextTheme textTheme, ) { @@ -253,7 +218,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) { @@ -261,15 +226,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( @@ -279,12 +250,13 @@ 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, + ), ); }, ); @@ -298,10 +270,20 @@ 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( @@ -316,7 +298,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) { @@ -324,23 +306,37 @@ 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, + ), + ); + } }, ); }, @@ -353,27 +349,35 @@ 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 ?? + 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), @@ -390,16 +394,16 @@ 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), ); } }, diff --git a/lib/headlines-feed/view/topic_filter_page.dart b/lib/headlines-feed/view/topic_filter_page.dart index d2425020..ff95c445 100644 --- a/lib/headlines-feed/view/topic_filter_page.dart +++ b/lib/headlines-feed/view/topic_filter_page.dart @@ -1,60 +1,21 @@ 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/headlines-feed/bloc/topics_filter_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/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; @@ -68,16 +29,17 @@ 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(); + 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 = - followedTopicsSet.isNotEmpty && - _pageSelectedTopics.length == followedTopicsSet.length && - _pageSelectedTopics.containsAll(followedTopicsSet); + followedTopics.isNotEmpty && + filterState.selectedTopics.length == followedTopics.length && + filterState.selectedTopics.containsAll(followedTopics); return IconButton( icon: isFollowedFilterActive @@ -87,156 +49,104 @@ 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: () { + if (followedTopics.isEmpty) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(l10n.noFollowedItemsForFilterSnackbar), + 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: 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, filterState) { + // Determine overall loading status for the main list + final isLoadingMainList = + filterState.status == HeadlinesFilterStatus.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 (filterState.status == HeadlinesFilterStatus.failure && + filterState.allTopics.isEmpty) { + return Center( + child: FailureStateWidget( + 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, + ), ), - ); - } + ), + ); + } - if (state.topics.isEmpty) { - return InitialStateWidget( - icon: Icons.category_outlined, - headline: l10n.topicFilterEmptyHeadline, - subheadline: l10n.topicFilterEmptySubheadline, - ); - } + if (filterState.allTopics.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()); - } - 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); - } - }); - }, + return ListView.builder( + itemCount: filterState.allTopics.length, + itemBuilder: (context, index) { + final topic = filterState.allTopics[index]; + final isSelected = filterState.selectedTopics.contains(topic); + return CheckboxListTile( + title: Text(topic.name), + value: isSelected, + onChanged: (bool? value) { + if (value != null) { + context.read().add( + FilterTopicToggled(topic: topic, isSelected: value), ); - }, - ), - // 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, - ), - ), - ], - ), - ), - ), - ), - ], - ); - }, - ), + } + }, + ); + }, + ); + }, ), ); } diff --git a/lib/headlines-search/bloc/headlines_search_bloc.dart b/lib/headlines-search/bloc/headlines_search_bloc.dart index 365827a8..8d24219e 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}, @@ -244,8 +261,7 @@ class HeadlinesSearchBloc feedItems: headlines, user: currentUser, adConfig: appConfig.adConfig, - imageStyle: - _appBloc.state.settings.feedPreferences.headlineImageStyle, + 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 57ac79ee..6f7358a1 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,17 +54,15 @@ 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), ); } @@ -128,69 +118,64 @@ 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( @@ -207,7 +192,6 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ), border: InputBorder.none, filled: false, - // fillColor: colorScheme.surface.withAlpha(26), contentPadding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.sm, @@ -234,7 +218,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), ], @@ -248,7 +231,6 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { subheadline: l10n.searchPageInitialSubheadline, ), HeadlinesSearchLoading() => LoadingStateWidget( - // Use LoadingStateWidget icon: Icons.search_outlined, headline: l10n.headlinesFeedLoadingHeadline, subheadline: l10n.searchingFor( @@ -274,7 +256,6 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ) : items.isEmpty ? InitialStateWidget( - // Use InitialStateWidget for no results as it's not a failure icon: Icons.search_off_outlined, headline: l10n.headlinesSearchNoResultsHeadline, subheadline: @@ -283,7 +264,6 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { : ListView.separated( controller: _scrollController, padding: const EdgeInsets.symmetric( - // Consistent padding horizontal: AppSpacing.paddingMedium, vertical: AppSpacing.paddingSmall, ).copyWith(bottom: AppSpacing.xxl), @@ -305,16 +285,13 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { final imageStyle = context .watch() .state - .settings - .feedPreferences - .headlineImageStyle; + .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; await context.pushNamed( @@ -349,16 +326,13 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { } 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() + .watch() .state .remoteConfig - ?.adConfig; + ?.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(); } 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 diff --git a/lib/router/router.dart b/lib/router/router.dart index 56ab4b9a..f4f3e6ce 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'; @@ -27,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'; @@ -71,12 +67,6 @@ GoRouter createRouter({ required GlobalKey navigatorKey, required InlineAdCacheService inlineAdCacheService, }) { - // Instantiate AccountBloc once to be shared - final accountBloc = AccountBloc( - authenticationRepository: authenticationRepository, - userContentPreferencesRepository: userContentPreferencesRepository, - ); - // Instantiate FeedDecoratorService once to be shared final feedDecoratorService = FeedDecoratorService( topicsRepository: topicsRepository, @@ -283,7 +273,6 @@ GoRouter createRouter({ final adThemeStyle = AdThemeStyle.fromTheme(Theme.of(context)); return MultiBlocProvider( providers: [ - BlocProvider.value(value: accountBloc), BlocProvider( create: (context) => EntityDetailsBloc( @@ -293,7 +282,6 @@ GoRouter createRouter({ sourceRepository: context.read>(), countryRepository: context .read>(), - accountBloc: accountBloc, appBloc: context.read(), feedDecoratorService: feedDecoratorService, inlineAdCacheService: inlineAdCacheService, @@ -341,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>(), @@ -374,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, @@ -425,7 +403,6 @@ GoRouter createRouter({ return MultiBlocProvider( providers: [ - BlocProvider.value(value: accountBloc), BlocProvider( create: (context) => HeadlineDetailsBloc( headlinesRepository: context @@ -478,70 +455,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, ), ); }, @@ -569,7 +498,6 @@ GoRouter createRouter({ final headlineIdFromPath = state.pathParameters['id']; return MultiBlocProvider( providers: [ - BlocProvider.value(value: accountBloc), BlocProvider( create: (context) => HeadlineDetailsBloc( headlinesRepository: context @@ -751,7 +679,6 @@ GoRouter createRouter({ final headlineIdFromPath = state.pathParameters['id']; return MultiBlocProvider( providers: [ - BlocProvider.value(value: accountBloc), BlocProvider( create: (context) => HeadlineDetailsBloc( headlinesRepository: context 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 } } diff --git a/lib/settings/view/feed_settings_page.dart b/lib/settings/view/feed_settings_page.dart index 2ee5ac1c..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 AppSettingsRefreshed()); + context.read().add(const AppUserAppSettingsRefreshed()); } }, child: Scaffold( 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( 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) { diff --git a/lib/status/view/critical_error_page.dart b/lib/status/view/critical_error_page.dart new file mode 100644 index 00000000..8561955f --- /dev/null +++ b/lib/status/view/critical_error_page.dart @@ -0,0 +1,43 @@ +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'; + +/// {@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';