diff --git a/lib/account/bloc/account_bloc.dart b/lib/account/bloc/account_bloc.dart index 56320825..1e0984ee 100644 --- a/lib/account/bloc/account_bloc.dart +++ b/lib/account/bloc/account_bloc.dart @@ -6,6 +6,7 @@ import 'package:ht_auth_repository/ht_auth_repository.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_main/app/config/config.dart' as local_config; import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; part 'account_event.dart'; part 'account_state.dart'; @@ -16,9 +17,11 @@ class AccountBloc extends Bloc { required HtDataRepository userContentPreferencesRepository, required local_config.AppEnvironment environment, + Logger? logger, }) : _authenticationRepository = authenticationRepository, _userContentPreferencesRepository = userContentPreferencesRepository, _environment = environment, + _logger = logger ?? Logger('AccountBloc'), super(const AccountState()) { // Listen to user changes from HtAuthRepository _userSubscription = _authenticationRepository.authStateChanges.listen(( @@ -31,9 +34,8 @@ class AccountBloc extends Bloc { on(_onAccountUserChanged); on(_onAccountLoadUserPreferences); on(_onAccountSaveHeadlineToggled); - on(_onAccountFollowCategoryToggled); + on(_onAccountFollowTopicToggled); on(_onAccountFollowSourceToggled); - // AccountFollowCountryToggled handler removed on(_onAccountClearUserPreferences); } @@ -41,6 +43,7 @@ class AccountBloc extends Bloc { final HtDataRepository _userContentPreferencesRepository; final local_config.AppEnvironment _environment; + final Logger _logger; late StreamSubscription _userSubscription; Future _onAccountUserChanged( @@ -72,7 +75,7 @@ class AccountBloc extends Bloc { state.copyWith( status: AccountStatus.success, preferences: preferences, - clearErrorMessage: true, + clearError: true, ), ); } on NotFoundException { @@ -86,8 +89,8 @@ class AccountBloc extends Bloc { if (_environment == local_config.AppEnvironment.demo) { // ignore: inference_failure_on_instance_creation await Future.delayed(const Duration(milliseconds: 50)); - // After delay, re-attempt to read the preferences. - // This is crucial because migration might have completed during the delay. + // After delay, re-attempt to read the preferences. This is crucial + // because migration might have completed during the delay. try { final migratedPreferences = await _userContentPreferencesRepository .read(id: event.userId, userId: event.userId); @@ -95,21 +98,27 @@ class AccountBloc extends Bloc { state.copyWith( status: AccountStatus.success, preferences: migratedPreferences, - clearErrorMessage: true, + clearError: true, ), ); return; // Exit if successfully read after migration } on NotFoundException { // Still not found after delay, proceed to create default. - print( + _logger.info( '[AccountBloc] UserContentPreferences still not found after ' 'migration delay. Creating default preferences.', ); } } - // If preferences not found (either initially or after re-attempt), - // create a default one for the user. - final defaultPreferences = UserContentPreferences(id: event.userId); + // If preferences not found (either initially or after re-attempt), 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, @@ -117,17 +126,17 @@ class AccountBloc extends Bloc { ); emit( state.copyWith( - status: AccountStatus.success, preferences: defaultPreferences, - clearErrorMessage: true, + 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. - print( + // 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.', ); @@ -137,26 +146,44 @@ class AccountBloc extends Bloc { state.copyWith( status: AccountStatus.success, preferences: existingPreferences, - clearErrorMessage: true, + clearError: true, ), ); - } catch (e) { + } on HtHttpException catch (e) { + _logger.severe( + 'Failed to create default preferences with HtHttpException: $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, - errorMessage: 'Failed to create default preferences: $e', + error: OperationFailedException( + 'Failed to create default preferences: $e', + ), ), ); } } on HtHttpException catch (e) { - emit( - state.copyWith(status: AccountStatus.failure, errorMessage: e.message), + _logger.severe( + 'AccountLoadUserPreferences failed with HtHttpException: $e', + ); + emit(state.copyWith(status: AccountStatus.failure, error: e)); + } catch (e, st) { + _logger.severe( + 'AccountLoadUserPreferences failed with unexpected error: $e', + e, + st, ); - } catch (e) { emit( state.copyWith( status: AccountStatus.failure, - errorMessage: 'An unexpected error occurred.', + error: OperationFailedException('An unexpected error occurred: $e'), ), ); } @@ -197,46 +224,51 @@ class AccountBloc extends Bloc { state.copyWith( status: AccountStatus.success, preferences: updatedPrefs, - clearErrorMessage: true, + clearError: true, ), ); } on HtHttpException catch (e) { - emit( - state.copyWith(status: AccountStatus.failure, errorMessage: e.message), + _logger.severe( + 'AccountSaveHeadlineToggled failed with HtHttpException: $e', + ); + emit(state.copyWith(status: AccountStatus.failure, error: e)); + } catch (e, st) { + _logger.severe( + 'AccountSaveHeadlineToggled failed with unexpected error: $e', + e, + st, ); - } catch (e) { emit( state.copyWith( status: AccountStatus.failure, - errorMessage: 'Failed to update saved headlines.', + error: OperationFailedException( + 'Failed to update saved headlines: $e', + ), ), ); } } - Future _onAccountFollowCategoryToggled( - AccountFollowCategoryToggled event, + 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.followedCategories.any( - (c) => c.id == event.category.id, + final isCurrentlyFollowed = currentPrefs.followedTopics.any( + (t) => t.id == event.topic.id, ); - final List updatedFollowedCategories; + final List updatedFollowedTopics; - if (isCurrentlyFollowed) { - updatedFollowedCategories = List.from(currentPrefs.followedCategories) - ..removeWhere((c) => c.id == event.category.id); - } else { - updatedFollowedCategories = List.from(currentPrefs.followedCategories) - ..add(event.category); - } + updatedFollowedTopics = isCurrentlyFollowed + ? (List.from(currentPrefs.followedTopics) + ..removeWhere((t) => t.id == event.topic.id)) + : (List.from(currentPrefs.followedTopics)..add(event.topic)); final updatedPrefs = currentPrefs.copyWith( - followedCategories: updatedFollowedCategories, + followedTopics: updatedFollowedTopics, ); try { @@ -249,18 +281,26 @@ class AccountBloc extends Bloc { state.copyWith( status: AccountStatus.success, preferences: updatedPrefs, - clearErrorMessage: true, + clearError: true, ), ); } on HtHttpException catch (e) { - emit( - state.copyWith(status: AccountStatus.failure, errorMessage: e.message), + _logger.severe( + 'AccountFollowTopicToggled failed with HtHttpException: $e', + ); + emit(state.copyWith(status: AccountStatus.failure, error: e)); + } catch (e, st) { + _logger.severe( + 'AccountFollowTopicToggled failed with unexpected error: $e', + e, + st, ); - } catch (e) { emit( state.copyWith( status: AccountStatus.failure, - errorMessage: 'Failed to update followed categories.', + error: OperationFailedException( + 'Failed to update followed topics: $e', + ), ), ); } @@ -301,25 +341,31 @@ class AccountBloc extends Bloc { state.copyWith( status: AccountStatus.success, preferences: updatedPrefs, - clearErrorMessage: true, + clearError: true, ), ); } on HtHttpException catch (e) { - emit( - state.copyWith(status: AccountStatus.failure, errorMessage: e.message), + _logger.severe( + 'AccountFollowSourceToggled failed with HtHttpException: $e', + ); + emit(state.copyWith(status: AccountStatus.failure, error: e)); + } catch (e, st) { + _logger.severe( + 'AccountFollowSourceToggled failed with unexpected error: $e', + e, + st, ); - } catch (e) { emit( state.copyWith( status: AccountStatus.failure, - errorMessage: 'Failed to update followed sources.', + error: OperationFailedException( + 'Failed to update followed sources: $e', + ), ), ); } } - // _onAccountFollowCountryToggled method removed - Future _onAccountClearUserPreferences( AccountClearUserPreferences event, Emitter emit, @@ -327,7 +373,13 @@ class AccountBloc extends Bloc { emit(state.copyWith(status: AccountStatus.loading)); try { // Create a new default preferences object to "clear" existing ones - final defaultPreferences = UserContentPreferences(id: event.userId); + final defaultPreferences = UserContentPreferences( + id: event.userId, + followedCountries: const [], + followedSources: const [], + followedTopics: const [], + savedHeadlines: const [], + ); await _userContentPreferencesRepository.update( id: event.userId, item: defaultPreferences, @@ -337,18 +389,26 @@ class AccountBloc extends Bloc { state.copyWith( status: AccountStatus.success, preferences: defaultPreferences, - clearErrorMessage: true, + clearError: true, ), ); } on HtHttpException catch (e) { - emit( - state.copyWith(status: AccountStatus.failure, errorMessage: e.message), + _logger.severe( + 'AccountClearUserPreferences failed with HtHttpException: $e', + ); + emit(state.copyWith(status: AccountStatus.failure, error: e)); + } catch (e, st) { + _logger.severe( + 'AccountClearUserPreferences failed with unexpected error: $e', + e, + st, ); - } catch (e) { emit( state.copyWith( status: AccountStatus.failure, - errorMessage: 'Failed to clear user preferences.', + error: OperationFailedException( + 'Failed to clear user preferences: $e', + ), ), ); } diff --git a/lib/account/bloc/account_event.dart b/lib/account/bloc/account_event.dart index c6132a28..c736ad7f 100644 --- a/lib/account/bloc/account_event.dart +++ b/lib/account/bloc/account_event.dart @@ -33,12 +33,12 @@ class AccountSaveHeadlineToggled extends AccountEvent { List get props => [headline]; } -class AccountFollowCategoryToggled extends AccountEvent { - const AccountFollowCategoryToggled({required this.category}); - final Category category; +class AccountFollowTopicToggled extends AccountEvent { + const AccountFollowTopicToggled({required this.topic}); + final Topic topic; @override - List get props => [category]; + List get props => [topic]; } class AccountFollowSourceToggled extends AccountEvent { diff --git a/lib/account/bloc/account_state.dart b/lib/account/bloc/account_state.dart index 8521b5c0..4caab16e 100644 --- a/lib/account/bloc/account_state.dart +++ b/lib/account/bloc/account_state.dart @@ -7,33 +7,31 @@ class AccountState extends Equatable { this.status = AccountStatus.initial, this.user, this.preferences, - this.errorMessage, + this.error, }); final AccountStatus status; final User? user; final UserContentPreferences? preferences; - final String? errorMessage; + final HtHttpException? error; AccountState copyWith({ AccountStatus? status, User? user, UserContentPreferences? preferences, - String? errorMessage, + HtHttpException? error, bool clearUser = false, bool clearPreferences = false, - bool clearErrorMessage = false, + bool clearError = false, }) { return AccountState( status: status ?? this.status, user: clearUser ? null : user ?? this.user, preferences: clearPreferences ? null : preferences ?? this.preferences, - errorMessage: clearErrorMessage - ? null - : errorMessage ?? this.errorMessage, + error: clearError ? null : error ?? this.error, ); } @override - List get props => [status, user, preferences, errorMessage]; + List get props => [status, user, preferences, error]; } diff --git a/lib/account/bloc/available_topics_bloc.dart b/lib/account/bloc/available_topics_bloc.dart new file mode 100644 index 00000000..4c8e876f --- /dev/null +++ b/lib/account/bloc/available_topics_bloc.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; + +part 'available_topics_event.dart'; +part 'available_topics_state.dart'; + +class AvailableTopicsBloc + extends Bloc { + AvailableTopicsBloc({required HtDataRepository topicsRepository}) + : _topicsRepository = topicsRepository, + super(const AvailableTopicsState()) { + on(_onFetchAvailableTopics); + } + + final HtDataRepository _topicsRepository; + + Future _onFetchAvailableTopics( + FetchAvailableTopics event, + Emitter emit, + ) async { + if (state.status == AvailableTopicsStatus.loading || + state.status == AvailableTopicsStatus.success) { + return; + } + emit(state.copyWith(status: AvailableTopicsStatus.loading)); + try { + final response = await _topicsRepository.readAll(); + emit( + state.copyWith( + status: AvailableTopicsStatus.success, + availableTopics: response.items, + clearError: true, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith(status: AvailableTopicsStatus.failure, error: e.message), + ); + } catch (e) { + emit( + state.copyWith( + status: AvailableTopicsStatus.failure, + error: 'An unexpected error occurred while fetching topics.', + ), + ); + } + } +} diff --git a/lib/account/bloc/available_topics_event.dart b/lib/account/bloc/available_topics_event.dart new file mode 100644 index 00000000..baacaccc --- /dev/null +++ b/lib/account/bloc/available_topics_event.dart @@ -0,0 +1,12 @@ +part of 'available_topics_bloc.dart'; + +abstract class AvailableTopicsEvent extends Equatable { + const AvailableTopicsEvent(); + + @override + List get props => []; +} + +class FetchAvailableTopics extends AvailableTopicsEvent { + const FetchAvailableTopics(); +} diff --git a/lib/account/bloc/available_topics_state.dart b/lib/account/bloc/available_topics_state.dart new file mode 100644 index 00000000..a3785a8c --- /dev/null +++ b/lib/account/bloc/available_topics_state.dart @@ -0,0 +1,31 @@ +part of 'available_topics_bloc.dart'; + +enum AvailableTopicsStatus { initial, loading, success, failure } + +class AvailableTopicsState extends Equatable { + const AvailableTopicsState({ + this.status = AvailableTopicsStatus.initial, + this.availableTopics = const [], + this.error, + }); + + final AvailableTopicsStatus status; + final List availableTopics; + final String? error; + + AvailableTopicsState copyWith({ + AvailableTopicsStatus? status, + List? availableTopics, + String? error, + bool clearError = false, + }) { + return AvailableTopicsState( + status: status ?? this.status, + availableTopics: availableTopics ?? this.availableTopics, + error: clearError ? null : error ?? this.error, + ); + } + + @override + List get props => [status, availableTopics, error]; +} diff --git a/lib/account/view/account_page.dart b/lib/account/view/account_page.dart index 5fc04216..38b02c56 100644 --- a/lib/account/view/account_page.dart +++ b/lib/account/view/account_page.dart @@ -5,8 +5,8 @@ import 'package:ht_main/app/bloc/app_bloc.dart'; import 'package:ht_main/authentication/bloc/authentication_bloc.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; -import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_shared/ht_shared.dart' hide AppStatus; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template account_view} /// Displays the user's account information and actions. @@ -18,7 +18,7 @@ class AccountPage extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; // Watch AppBloc for user details and authentication status final appState = context.watch().state; final user = appState.user; @@ -83,7 +83,7 @@ class AccountPage extends StatelessWidget { } Widget _buildUserHeader(BuildContext context, User? user, bool isAnonymous) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; @@ -125,17 +125,6 @@ class AccountPage extends StatelessWidget { statusWidget = Column( mainAxisSize: MainAxisSize.min, children: [ - // if (user?.role != null) ...[ - // // Show role only if available - // const SizedBox(height: AppSpacing.xs), - // Text( - // l10n.accountRoleLabel(user!.role.name), - // style: textTheme.bodyMedium?.copyWith( - // color: colorScheme.onSurfaceVariant, - // ), - // textAlign: TextAlign.center, - // ), - // ], const SizedBox(height: AppSpacing.md), OutlinedButton.icon( // Changed to OutlinedButton.icon @@ -179,7 +168,7 @@ class AccountPage extends StatelessWidget { } Widget _buildSettingsTile(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; diff --git a/lib/account/view/manage_followed_items/categories/followed_categories_list_page.dart b/lib/account/view/manage_followed_items/categories/followed_categories_list_page.dart deleted file mode 100644 index 18bb44d5..00000000 --- a/lib/account/view/manage_followed_items/categories/followed_categories_list_page.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import 'package:ht_main/account/bloc/account_bloc.dart'; -import 'package:ht_main/entity_details/view/entity_details_page.dart'; -import 'package:ht_main/l10n/l10n.dart'; -import 'package:ht_main/router/routes.dart'; -import 'package:ht_main/shared/widgets/widgets.dart'; - -/// {@template followed_categories_list_page} -/// Page to display and manage categories followed by the user. -/// {@endtemplate} -class FollowedCategoriesListPage extends StatelessWidget { - /// {@macro followed_categories_list_page} - const FollowedCategoriesListPage({super.key}); - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final followedCategories = - context.watch().state.preferences?.followedCategories ?? - []; - - return Scaffold( - appBar: AppBar( - title: const Text('Followed Categories'), - actions: [ - IconButton( - icon: const Icon(Icons.add_circle_outline), - tooltip: 'Add Category to Follow', - onPressed: () { - context.goNamed(Routes.addCategoryToFollowName); - }, - ), - ], - ), - body: BlocBuilder( - builder: (context, state) { - if (state.status == AccountStatus.loading && - state.preferences == null) { - return LoadingStateWidget( - icon: Icons.category_outlined, - headline: 'Loading Followed Categories...', - subheadline: l10n.pleaseWait, - ); - } - - if (state.status == AccountStatus.failure && - state.preferences == null) { - return FailureStateWidget( - message: - state.errorMessage ?? 'Could not load followed categories.', - onRetry: () { - if (state.user?.id != null) { - context.read().add( - AccountLoadUserPreferences(userId: state.user!.id), - ); - } - }, - ); - } - - if (followedCategories.isEmpty) { - return const InitialStateWidget( - icon: Icons.no_sim_outlined, - headline: 'No Followed Categories', - subheadline: 'Start following categories to see them here.', - ); - } - - return ListView.builder( - itemCount: followedCategories.length, - itemBuilder: (context, index) { - final category = followedCategories[index]; - return ListTile( - leading: category.iconUrl != null - ? SizedBox( - width: 40, - height: 40, - child: Image.network( - category.iconUrl!, - errorBuilder: (context, error, stackTrace) => - const Icon(Icons.category_outlined), - ), - ) - : const Icon(Icons.category_outlined), - title: Text(category.name), - subtitle: category.description != null - ? Text( - category.description!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : null, - trailing: IconButton( - icon: const Icon( - Icons.remove_circle_outline, - color: Colors.red, - ), - tooltip: 'Unfollow Category', - onPressed: () { - context.read().add( - AccountFollowCategoryToggled(category: category), - ); - }, - ), - onTap: () { - context.push( - Routes.categoryDetails, - extra: EntityDetailsPageArguments(entity: category), - ); - }, - ); - }, - ); - }, - ), - ); - } -} diff --git a/lib/account/view/manage_followed_items/manage_followed_items_page.dart b/lib/account/view/manage_followed_items/manage_followed_items_page.dart index 2e8ec39c..d37208f6 100644 --- a/lib/account/view/manage_followed_items/manage_followed_items_page.dart +++ b/lib/account/view/manage_followed_items/manage_followed_items_page.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template manage_followed_items_page} /// Page for navigating to lists of followed content types like -/// categories, sources, and countries. +/// topics, sources, and countries. /// {@endtemplate} class ManageFollowedItemsPage extends StatelessWidget { /// {@macro manage_followed_items_page} @@ -14,7 +14,7 @@ class ManageFollowedItemsPage extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; @@ -30,14 +30,14 @@ class ManageFollowedItemsPage extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: AppSpacing.paddingSmall), children: [ ListTile( - leading: Icon(Icons.category_outlined, color: colorScheme.primary), + leading: Icon(Icons.topic_outlined, color: colorScheme.primary), title: Text( - l10n.headlinesFeedFilterCategoryLabel, + l10n.headlinesFeedFilterTopicLabel, style: textTheme.titleMedium, ), trailing: const Icon(Icons.chevron_right), onTap: () { - context.goNamed(Routes.followedCategoriesListName); + context.goNamed(Routes.followedTopicsListName); }, ), const Divider( @@ -59,7 +59,6 @@ class ManageFollowedItemsPage extends StatelessWidget { indent: AppSpacing.paddingMedium, endIndent: AppSpacing.paddingMedium, ), - // ListTile for Followed Countries removed ], ), ); 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 b533ed94..da44590f 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,11 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_main/account/bloc/account_bloc.dart'; -import 'package:ht_main/headlines-feed/bloc/sources_filter_bloc.dart'; +import 'package:ht_main/account/bloc/available_sources_bloc.dart'; import 'package:ht_main/l10n/l10n.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; -import 'package:ht_main/shared/widgets/widgets.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template add_source_to_follow_page} /// A page that allows users to browse and select sources to follow. @@ -17,34 +16,34 @@ class AddSourceToFollowPage extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; return BlocProvider( - create: (context) => SourcesFilterBloc( + create: (context) => AvailableSourcesBloc( sourcesRepository: context.read>(), - countriesRepository: context.read>(), - )..add(const LoadSourceFilterData()), + )..add(const FetchAvailableSources()), child: Scaffold( appBar: AppBar(title: Text(l10n.addSourcesPageTitle)), - body: BlocBuilder( + body: BlocBuilder( builder: (context, sourcesState) { - if (sourcesState.dataLoadingStatus == - SourceFilterDataLoadingStatus.loading || - sourcesState.dataLoadingStatus == - SourceFilterDataLoadingStatus.initial) { + if (sourcesState.status == AvailableSourcesStatus.loading || + sourcesState.status == AvailableSourcesStatus.initial) { return const Center(child: CircularProgressIndicator()); } - if (sourcesState.dataLoadingStatus == - SourceFilterDataLoadingStatus.failure) { + if (sourcesState.status == AvailableSourcesStatus.failure) { return FailureStateWidget( - message: sourcesState.errorMessage ?? l10n.sourceFilterError, - onRetry: () => context.read().add( - const LoadSourceFilterData(), + exception: OperationFailedException( + sourcesState.error ?? l10n.sourceFilterError, + ), + onRetry: () => context.read().add( + const FetchAvailableSources(), ), ); } - if (sourcesState.allAvailableSources.isEmpty) { - return FailureStateWidget( - message: l10n.sourceFilterEmptyHeadline, + if (sourcesState.availableSources.isEmpty) { + return InitialStateWidget( + icon: Icons.source_outlined, + headline: l10n.sourceFilterEmptyHeadline, + subheadline: l10n.sourceFilterEmptySubheadline, ); } @@ -59,9 +58,9 @@ class AddSourceToFollowPage extends StatelessWidget { return ListView.builder( padding: const EdgeInsets.all(AppSpacing.md), - itemCount: sourcesState.allAvailableSources.length, + itemCount: sourcesState.availableSources.length, itemBuilder: (context, index) { - final source = sourcesState.allAvailableSources[index]; + final source = sourcesState.availableSources[index]; final isFollowed = followedSources.any( (fs) => fs.id == source.id, ); 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 611973d5..a92ee144 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 @@ -5,7 +5,8 @@ import 'package:ht_main/account/bloc/account_bloc.dart'; import 'package:ht_main/entity_details/view/entity_details_page.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; -import 'package:ht_main/shared/widgets/widgets.dart'; +import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template followed_sources_list_page} /// Page to display and manage sources followed by the user. @@ -16,17 +17,17 @@ class FollowedSourcesListPage extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final followedSources = context.watch().state.preferences?.followedSources ?? []; return Scaffold( appBar: AppBar( - title: const Text('Followed Sources'), + title: Text(l10n.followedSourcesPageTitle), actions: [ IconButton( icon: const Icon(Icons.add_circle_outline), - tooltip: 'Add Source to Follow', + tooltip: l10n.addSourcesTooltip, onPressed: () { context.goNamed(Routes.addSourceToFollowName); }, @@ -39,7 +40,7 @@ class FollowedSourcesListPage extends StatelessWidget { state.preferences == null) { return LoadingStateWidget( icon: Icons.source_outlined, - headline: 'Loading Followed Sources...', + headline: l10n.followedSourcesLoadingHeadline, subheadline: l10n.pleaseWait, ); } @@ -47,14 +48,13 @@ class FollowedSourcesListPage extends StatelessWidget { if (state.status == AccountStatus.failure && state.preferences == null) { return FailureStateWidget( - message: state.errorMessage ?? 'Could not load followed sources.', + exception: + state.error ?? + OperationFailedException(l10n.followedSourcesErrorHeadline), onRetry: () { if (state.user?.id != null) { context.read().add( - AccountLoadUserPreferences( - // Corrected event name - userId: state.user!.id, - ), + AccountLoadUserPreferences(userId: state.user!.id), ); } }, @@ -62,10 +62,10 @@ class FollowedSourcesListPage extends StatelessWidget { } if (followedSources.isEmpty) { - return const InitialStateWidget( + return InitialStateWidget( icon: Icons.no_sim_outlined, - headline: 'No Followed Sources', - subheadline: 'Start following sources to see them here.', + headline: l10n.followedSourcesEmptyHeadline, + subheadline: l10n.followedSourcesEmptySubheadline, ); } @@ -76,19 +76,17 @@ class FollowedSourcesListPage extends StatelessWidget { return ListTile( leading: const Icon(Icons.source_outlined), title: Text(source.name), - subtitle: source.description != null - ? Text( - source.description!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : null, + subtitle: Text( + source.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), trailing: IconButton( icon: const Icon( Icons.remove_circle_outline, color: Colors.red, ), - tooltip: 'Unfollow Source', + tooltip: l10n.unfollowSourceTooltip(source.name), onPressed: () { context.read().add( AccountFollowSourceToggled(source: source), diff --git a/lib/account/view/manage_followed_items/categories/add_category_to_follow_page.dart b/lib/account/view/manage_followed_items/topics/add_topic_to_follow_page.dart similarity index 55% rename from lib/account/view/manage_followed_items/categories/add_category_to_follow_page.dart rename to lib/account/view/manage_followed_items/topics/add_topic_to_follow_page.dart index 113caac0..fe81b9be 100644 --- a/lib/account/view/manage_followed_items/categories/add_category_to_follow_page.dart +++ b/lib/account/view/manage_followed_items/topics/add_topic_to_follow_page.dart @@ -2,107 +2,80 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_main/account/bloc/account_bloc.dart'; -import 'package:ht_main/headlines-feed/bloc/categories_filter_bloc.dart'; +import 'package:ht_main/account/bloc/available_topics_bloc.dart'; import 'package:ht_main/l10n/l10n.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; -import 'package:ht_main/shared/widgets/widgets.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; -/// {@template add_category_to_follow_page} -/// A page that allows users to browse and select categories to follow. +/// {@template add_topic_to_follow_page} +/// A page that allows users to browse and select topics to follow. /// {@endtemplate} -class AddCategoryToFollowPage extends StatelessWidget { - /// {@macro add_category_to_follow_page} - const AddCategoryToFollowPage({super.key}); +class AddTopicToFollowPage extends StatelessWidget { + /// {@macro add_topic_to_follow_page} + const AddTopicToFollowPage({super.key}); @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; return BlocProvider( - create: (context) => CategoriesFilterBloc( - categoriesRepository: context.read>(), - )..add(CategoriesFilterRequested()), + create: (context) => AvailableTopicsBloc( + topicsRepository: context.read>(), + )..add(const FetchAvailableTopics()), child: Scaffold( appBar: AppBar( - title: Text(l10n.addCategoriesPageTitle, style: textTheme.titleLarge), + title: Text(l10n.addTopicsPageTitle, style: textTheme.titleLarge), ), - body: BlocBuilder( - builder: (context, categoriesState) { - if (categoriesState.status == CategoriesFilterStatus.loading && - categoriesState.categories.isEmpty) { - // Show full loading only if list is empty + body: BlocBuilder( + builder: (context, topicsState) { + if (topicsState.status == AvailableTopicsStatus.loading) { return LoadingStateWidget( - icon: Icons.category_outlined, - headline: l10n.categoryFilterLoadingHeadline, - subheadline: l10n.categoryFilterLoadingSubheadline, + icon: Icons.topic_outlined, + headline: l10n.topicFilterLoadingHeadline, + subheadline: l10n.pleaseWait, ); } - if (categoriesState.status == CategoriesFilterStatus.failure && - categoriesState.categories.isEmpty) { - // Show full error only if list is empty - var errorMessage = l10n.categoryFilterError; - if (categoriesState.error is HtHttpException) { - errorMessage = - (categoriesState.error! as HtHttpException).message; - } else if (categoriesState.error != null) { - errorMessage = categoriesState.error.toString(); - } + if (topicsState.status == AvailableTopicsStatus.failure) { return FailureStateWidget( - message: errorMessage, - onRetry: () => context.read().add( - CategoriesFilterRequested(), + exception: OperationFailedException( + topicsState.error ?? l10n.topicFilterError, + ), + onRetry: () => context.read().add( + const FetchAvailableTopics(), ), ); } - if (categoriesState.categories.isEmpty && - categoriesState.status == CategoriesFilterStatus.success) { - // Show empty only on success + if (topicsState.availableTopics.isEmpty) { return InitialStateWidget( - // Use InitialStateWidget for empty icon: Icons.search_off_outlined, - headline: l10n.categoryFilterEmptyHeadline, - subheadline: l10n.categoryFilterEmptySubheadline, + headline: l10n.topicFilterEmptyHeadline, + subheadline: l10n.topicFilterEmptySubheadline, ); } - // Handle loading more at the bottom or list display - final categories = categoriesState.categories; - final isLoadingMore = - categoriesState.status == CategoriesFilterStatus.loadingMore; + final topics = topicsState.availableTopics; return BlocBuilder( buildWhen: (previous, current) => - previous.preferences?.followedCategories != - current.preferences?.followedCategories || + previous.preferences?.followedTopics != + current.preferences?.followedTopics || previous.status != current.status, builder: (context, accountState) { - final followedCategories = - accountState.preferences?.followedCategories ?? []; + final followedTopics = + accountState.preferences?.followedTopics ?? []; return ListView.builder( padding: const EdgeInsets.symmetric( - // Consistent padding horizontal: AppSpacing.paddingMedium, vertical: AppSpacing.paddingSmall, ).copyWith(bottom: AppSpacing.xxl), - itemCount: categories.length + (isLoadingMore ? 1 : 0), + itemCount: topics.length, itemBuilder: (context, index) { - if (index == categories.length && isLoadingMore) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), - child: Center(child: CircularProgressIndicator()), - ); - } - if (index >= categories.length) { - return const SizedBox.shrink(); - } - - final category = categories[index]; - final isFollowed = followedCategories.any( - (fc) => fc.id == category.id, + final topic = topics[index]; + final isFollowed = followedTopics.any( + (ft) => ft.id == topic.id, ); final colorScheme = Theme.of(context).colorScheme; @@ -117,23 +90,19 @@ class AddCategoryToFollowPage extends StatelessWidget { ), child: ListTile( leading: SizedBox( - // Standardized leading icon/image size width: AppSpacing.xl + AppSpacing.xs, height: AppSpacing.xl + AppSpacing.xs, - child: - category.iconUrl != null && - Uri.tryParse(category.iconUrl!)?.isAbsolute == - true + child: Uri.tryParse(topic.iconUrl)?.isAbsolute == true ? ClipRRect( borderRadius: BorderRadius.circular( AppSpacing.xs, ), child: Image.network( - category.iconUrl!, + topic.iconUrl, fit: BoxFit.contain, errorBuilder: (context, error, stackTrace) => Icon( - Icons.category_outlined, + Icons.topic_outlined, color: colorScheme.onSurfaceVariant, size: AppSpacing.lg, ), @@ -160,15 +129,12 @@ class AddCategoryToFollowPage extends StatelessWidget { ), ) : Icon( - Icons.category_outlined, + Icons.topic_outlined, color: colorScheme.onSurfaceVariant, size: AppSpacing.lg, ), ), - title: Text( - category.name, - style: textTheme.titleMedium, - ), + title: Text(topic.name, style: textTheme.titleMedium), trailing: IconButton( icon: isFollowed ? Icon( @@ -180,16 +146,15 @@ class AddCategoryToFollowPage extends StatelessWidget { color: colorScheme.onSurfaceVariant, ), tooltip: isFollowed - ? l10n.unfollowCategoryTooltip(category.name) - : l10n.followCategoryTooltip(category.name), + ? l10n.unfollowTopicTooltip(topic.name) + : l10n.followTopicTooltip(topic.name), onPressed: () { context.read().add( - AccountFollowCategoryToggled(category: category), + AccountFollowTopicToggled(topic: topic), ); }, ), contentPadding: const EdgeInsets.symmetric( - // Consistent padding horizontal: AppSpacing.paddingMedium, vertical: AppSpacing.xs, ), 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 new file mode 100644 index 00000000..a5e6e94f --- /dev/null +++ b/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_main/account/bloc/account_bloc.dart'; +import 'package:ht_main/entity_details/view/entity_details_page.dart'; +import 'package:ht_main/l10n/l10n.dart'; +import 'package:ht_main/router/routes.dart'; +import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; + +/// {@template followed_topics_list_page} +/// Page to display and manage topics followed by the user. +/// {@endtemplate} +class FollowedTopicsListPage extends StatelessWidget { + /// {@macro followed_topics_list_page} + const FollowedTopicsListPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + final followedTopics = + context.watch().state.preferences?.followedTopics ?? []; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.followedTopicsPageTitle), + actions: [ + IconButton( + icon: const Icon(Icons.add_circle_outline), + tooltip: l10n.addTopicsTooltip, + onPressed: () { + context.goNamed(Routes.addTopicToFollowName); + }, + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + if (state.status == AccountStatus.loading && + state.preferences == null) { + return LoadingStateWidget( + icon: Icons.topic_outlined, + headline: l10n.followedTopicsLoadingHeadline, + subheadline: l10n.pleaseWait, + ); + } + + if (state.status == AccountStatus.failure && + state.preferences == null) { + return FailureStateWidget( + exception: + state.error ?? + OperationFailedException(l10n.followedTopicsErrorHeadline), + onRetry: () { + if (state.user?.id != null) { + context.read().add( + AccountLoadUserPreferences(userId: state.user!.id), + ); + } + }, + ); + } + + if (followedTopics.isEmpty) { + return InitialStateWidget( + icon: Icons.no_sim_outlined, + headline: l10n.followedTopicsEmptyHeadline, + subheadline: l10n.followedTopicsEmptySubheadline, + ); + } + + return ListView.builder( + itemCount: followedTopics.length, + itemBuilder: (context, index) { + final topic = followedTopics[index]; + return ListTile( + leading: SizedBox( + width: 40, + height: 40, + child: Image.network( + topic.iconUrl, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.topic_outlined), + ), + ), + title: Text(topic.name), + subtitle: Text( + topic.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: const Icon( + Icons.remove_circle_outline, + color: Colors.red, + ), + tooltip: l10n.unfollowTopicTooltip(topic.name), + onPressed: () { + context.read().add( + AccountFollowTopicToggled(topic: topic), + ); + }, + ), + onTap: () { + context.push( + Routes.topicDetails, + extra: EntityDetailsPageArguments(entity: topic), + ); + }, + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/account/view/saved_headlines_page.dart b/lib/account/view/saved_headlines_page.dart index c2f44fcd..d545b928 100644 --- a/lib/account/view/saved_headlines_page.dart +++ b/lib/account/view/saved_headlines_page.dart @@ -8,6 +8,7 @@ import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; import 'package:ht_main/shared/shared.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template saved_headlines_page} /// Displays the list of headlines saved by the user. @@ -21,7 +22,7 @@ class SavedHeadlinesPage extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; @@ -47,7 +48,9 @@ class SavedHeadlinesPage extends StatelessWidget { if (state.status == AccountStatus.failure && state.preferences == null) { return FailureStateWidget( - message: state.errorMessage ?? l10n.savedHeadlinesErrorHeadline, + exception: + state.error ?? + OperationFailedException(l10n.savedHeadlinesErrorHeadline), onRetry: () { if (state.user?.id != null) { context.read().add( diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index d256a55b..7511698f 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:ht_auth_repository/ht_auth_repository.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_main/app/config/config.dart' as local_config; -import 'package:ht_main/shared/services/demo_data_migration_service.dart'; +import 'package:ht_main/app/services/demo_data_migration_service.dart'; import 'package:ht_shared/ht_shared.dart'; part 'app_event.dart'; @@ -17,7 +17,7 @@ class AppBloc extends Bloc { AppBloc({ required HtAuthRepository authenticationRepository, required HtDataRepository userAppSettingsRepository, - required HtDataRepository appConfigRepository, + required HtDataRepository appConfigRepository, required local_config.AppEnvironment environment, this.demoDataMigrationService, }) : _authenticationRepository = authenticationRepository, @@ -26,9 +26,25 @@ class AppBloc extends Bloc { _environment = environment, super( AppState( - settings: const UserAppSettings(id: 'default'), + settings: const UserAppSettings( + id: 'default', + displaySettings: DisplaySettings( + baseTheme: AppBaseTheme.system, + accentTheme: AppAccentTheme.defaultBlue, + fontFamily: 'SystemDefault', + textScaleFactor: AppTextScaleFactor.medium, + fontWeight: AppFontWeight.regular, + ), + language: 'en', + feedPreferences: FeedDisplayPreferences( + headlineDensity: HeadlineDensity.standard, + headlineImageStyle: HeadlineImageStyle.largeThumbnail, + showSourceInHeadlineFeed: true, + showPublishDateInHeadlineFeed: true, + ), + ), selectedBottomNavigationIndex: 0, - appConfig: null, + remoteConfig: null, environment: environment, ), ) { @@ -41,6 +57,8 @@ class AppBloc extends Bloc { on(_onFlexSchemeChanged); on(_onFontFamilyChanged); on(_onAppTextScaleFactorChanged); + on(_onAppFontWeightChanged); + on(_onAppOpened); // Listen directly to the auth state changes stream _userSubscription = _authenticationRepository.authStateChanges.listen( @@ -50,7 +68,7 @@ class AppBloc extends Bloc { final HtAuthRepository _authenticationRepository; final HtDataRepository _userAppSettingsRepository; - final HtDataRepository _appConfigRepository; + final HtDataRepository _appConfigRepository; final local_config.AppEnvironment _environment; final DemoDataMigrationService? demoDataMigrationService; late final StreamSubscription _userSubscription; @@ -64,12 +82,12 @@ class AppBloc extends Bloc { final AppStatus status; final oldUser = state.user; - switch (event.user?.role) { + switch (event.user?.appRole) { case null: status = AppStatus.unauthenticated; - case UserRole.standardUser: + case AppUserRole.standardUser: status = AppStatus.authenticated; - case UserRole.guestUser: // Explicitly map guestUser to anonymous + case AppUserRole.guestUser: // Explicitly map guestUser to anonymous status = AppStatus.anonymous; // ignore: no_default_cases default: // Fallback for any other roles not explicitly handled @@ -86,8 +104,8 @@ class AppBloc extends Bloc { // Check for anonymous to authenticated transition for data migration if (oldUser != null && - oldUser.role == UserRole.guestUser && - event.user!.role == UserRole.standardUser) { + oldUser.appRole == AppUserRole.guestUser && + event.user!.appRole == AppUserRole.standardUser) { print( '[AppBloc] Anonymous user ${oldUser.id} transitioned to ' 'authenticated user ${event.user!.id}. Attempting data migration.', @@ -123,7 +141,7 @@ class AppBloc extends Bloc { // User is null (unauthenticated or logged out) emit( state.copyWith( - appConfig: null, + remoteConfig: null, clearAppConfig: true, status: AppStatus.unauthenticated, ), @@ -195,7 +213,23 @@ class AppBloc extends Bloc { flexScheme: FlexScheme.material, appTextScaleFactor: AppTextScaleFactor.medium, locale: const Locale('en'), - settings: UserAppSettings(id: state.user!.id), + settings: UserAppSettings( + id: state.user!.id, + displaySettings: const DisplaySettings( + baseTheme: AppBaseTheme.system, + accentTheme: AppAccentTheme.defaultBlue, + fontFamily: 'SystemDefault', + textScaleFactor: AppTextScaleFactor.medium, + fontWeight: AppFontWeight.regular, + ), + language: 'en', + feedPreferences: const FeedDisplayPreferences( + headlineDensity: HeadlineDensity.standard, + headlineImageStyle: HeadlineImageStyle.largeThumbnail, + showSourceInHeadlineFeed: true, + showPublishDateInHeadlineFeed: true, + ), + ), ), ); } catch (e) { @@ -286,6 +320,21 @@ class AppBloc extends Bloc { // unawaited(_userAppSettingsRepository.update(id: updatedSettings.id, item: updatedSettings)); } + void _onAppFontWeightChanged( + AppFontWeightChanged event, + Emitter emit, + ) { + // Update settings and emit new state + final updatedSettings = state.settings.copyWith( + displaySettings: state.settings.displaySettings.copyWith( + fontWeight: event.fontWeight, + ), + ); + emit(state.copyWith(settings: updatedSettings)); + // Optionally save settings to repository here + // unawaited(_userAppSettingsRepository.update(id: updatedSettings.id, item: updatedSettings)); + } + // --- Settings Mapping Helpers --- ThemeMode _mapAppBaseTheme(AppBaseTheme mode) { @@ -349,10 +398,11 @@ class AppBloc extends Bloc { ); // If AppConfig was somehow present without a user, clear it. // And ensure status isn't stuck on configFetching if this event was dispatched erroneously. - if (state.appConfig != null || state.status == AppStatus.configFetching) { + if (state.remoteConfig != null || + state.status == AppStatus.configFetching) { emit( state.copyWith( - appConfig: null, + remoteConfig: null, clearAppConfig: true, status: AppStatus.unauthenticated, ), @@ -362,7 +412,7 @@ class AppBloc extends Bloc { } // Avoid refetching if already loaded for the current user session, unless explicitly trying to recover from a failed state. - if (state.appConfig != null && + if (state.remoteConfig != null && state.status != AppStatus.configFetchFailed) { print( '[AppBloc] AppConfig already loaded for user ${state.user?.id} and not in a failed state. Skipping fetch.', @@ -376,7 +426,7 @@ class AppBloc extends Bloc { emit( state.copyWith( status: AppStatus.configFetching, - appConfig: null, + remoteConfig: null, clearAppConfig: true, ), ); @@ -389,10 +439,13 @@ class AppBloc extends Bloc { // Determine the correct status based on the existing user's role. // This ensures that successfully fetching config doesn't revert auth status to 'initial'. - final newStatusBasedOnUser = state.user!.role == UserRole.standardUser + final newStatusBasedOnUser = + state.user!.appRole == AppUserRole.standardUser ? AppStatus.authenticated : AppStatus.anonymous; - emit(state.copyWith(appConfig: appConfig, status: newStatusBasedOnUser)); + emit( + state.copyWith(remoteConfig: appConfig, status: newStatusBasedOnUser), + ); } on HtHttpException catch (e) { print( '[AppBloc] Failed to fetch AppConfig (HtHttpException) for user ${state.user?.id}: ${e.runtimeType} - ${e.message}', @@ -400,7 +453,7 @@ class AppBloc extends Bloc { emit( state.copyWith( status: AppStatus.configFetchFailed, - appConfig: null, + remoteConfig: null, clearAppConfig: true, ), ); @@ -412,7 +465,7 @@ class AppBloc extends Bloc { emit( state.copyWith( status: AppStatus.configFetchFailed, - appConfig: null, + remoteConfig: null, clearAppConfig: true, ), ); @@ -425,29 +478,66 @@ class AppBloc extends Bloc { ) async { if (state.user != null && state.user!.id == event.userId) { final now = DateTime.now(); - // Optimistically update the local user state. - // Corrected parameter name for copyWith as per User model in models.txt - final updatedUser = state.user!.copyWith(lastEngagementShownAt: now); + // Create a new UserFeedActionStatus for the specific action + final updatedActionStatus = UserFeedActionStatus( + isCompleted: event.isCompleted, + lastShownAt: now, + ); + + // Create a new map with the updated status for the specific action type + final newFeedActionStatus = + Map.from( + state.user!.feedActionStatus, + )..update( + event.feedActionType, + (_) => updatedActionStatus, + ifAbsent: () => updatedActionStatus, + ); + + // Update the user with the new feedActionStatus map + final updatedUser = state.user!.copyWith( + feedActionStatus: newFeedActionStatus, + ); // Emit the change so UI can react if needed, and other BLoCs get the update. - // This also ensures that FeedInjectorService will see the updated timestamp immediately. emit(state.copyWith(user: updatedUser)); // TODO: Persist this change to the backend. // This would typically involve calling a method on a repository, e.g.: // try { - // await _authenticationRepository.updateUserLastActionTimestamp(event.userId, now); - // // If the repository's authStateChanges stream doesn't automatically emit - // // the updated user, you might need to re-fetch or handle it here. - // // For now, we've optimistically updated the local state. + // await _authenticationRepository.updateUserFeedActionStatus( + // event.userId, + // event.feedActionType, + // updatedActionStatus, + // ); // } catch (e) { - // // Handle error, potentially revert optimistic update or show an error. - // print('Failed to update lastAccountActionShownAt on backend: $e'); - // // Optionally revert: emit(state.copyWith(user: state.user)); + // print('Failed to update feed action status on backend: $e'); // } print( - '[AppBloc] User ${event.userId} AccountAction shown. Last shown timestamp updated locally to $now. Backend update pending.', + '[AppBloc] User ${event.userId} FeedAction ${event.feedActionType} ' + 'shown/completed. Status updated locally to $updatedActionStatus. ' + 'Backend update pending.', ); } } + + Future _onAppOpened(AppOpened event, Emitter emit) async { + if (state.remoteConfig == null) { + return; + } + + final appStatus = state.remoteConfig!.appStatus; + + if (appStatus.isUnderMaintenance) { + emit(state.copyWith(status: AppStatus.underMaintenance)); + return; + } + + // TODO(ht-development): Get the current app version from a package like + // package_info_plus and compare it with appStatus.latestAppVersion. + if (appStatus.isLatestVersionOnly) { + emit(state.copyWith(status: AppStatus.updateRequired)); + return; + } + } } diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart index 46997225..33fd3eee 100644 --- a/lib/app/bloc/app_event.dart +++ b/lib/app/bloc/app_event.dart @@ -7,13 +7,6 @@ abstract class AppEvent extends Equatable { List get props => []; } -@Deprecated('Use SettingsBloc events instead') -class AppThemeChanged extends AppEvent { - // - // ignore: deprecated_consistency - const AppThemeChanged(); -} - class AppUserChanged extends AppEvent { const AppUserChanged(this.user); @@ -92,6 +85,20 @@ class AppTextScaleFactorChanged extends AppEvent { List get props => [appTextScaleFactor]; } +/// {@template app_font_weight_changed} +/// Event to change the application's font weight. +/// {@endtemplate} +class AppFontWeightChanged extends AppEvent { + /// {@macro app_font_weight_changed} + const AppFontWeightChanged(this.fontWeight); + + /// The new font weight to apply. + final AppFontWeight fontWeight; + + @override + List get props => [fontWeight]; +} + /// {@template app_config_fetch_requested} /// Event to trigger fetching of the global AppConfig. /// {@endtemplate} @@ -100,16 +107,31 @@ class AppConfigFetchRequested extends AppEvent { const AppConfigFetchRequested(); } +/// {@template app_opened} +/// Event triggered when the application is opened. +/// Used to check for required updates or maintenance mode. +/// {@endtemplate} +class AppOpened extends AppEvent { + /// {@macro app_opened} + const AppOpened(); +} + /// {@template app_user_account_action_shown} /// Event triggered when an AccountAction has been shown to the user, /// prompting an update to their `lastAccountActionShownAt` timestamp. /// {@endtemplate} class AppUserAccountActionShown extends AppEvent { /// {@macro app_user_account_action_shown} - const AppUserAccountActionShown({required this.userId}); + const AppUserAccountActionShown({ + required this.userId, + required this.feedActionType, + required this.isCompleted, + }); final String userId; + final FeedActionType feedActionType; + final bool isCompleted; @override - List get props => [userId]; + List get props => [userId, feedActionType, isCompleted]; } diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index ed011a45..91b60f94 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -14,11 +14,17 @@ enum AppStatus { /// The user is anonymous (signed in using an anonymous provider). anonymous, - /// Fetching the essential AppConfig. + /// Fetching the essential RemoteConfig. configFetching, - /// Fetching the essential AppConfig failed. + /// Fetching the essential RemoteConfig failed. configFetchFailed, + + /// A new version of the app is required. + updateRequired, + + /// The app is currently under maintenance. + underMaintenance, } class AppState extends Equatable { @@ -30,10 +36,10 @@ class AppState extends Equatable { this.appTextScaleFactor = AppTextScaleFactor.medium, this.flexScheme = FlexScheme.material, this.fontFamily, - this.status = AppStatus.initial, + this.status = AppStatus.initial, // Changed from AppStatus this.user, this.locale, - this.appConfig, + this.remoteConfig, this.environment, }); @@ -66,7 +72,7 @@ class AppState extends Equatable { final Locale? locale; /// The global application configuration (remote config). - final AppConfig? appConfig; + final RemoteConfig? remoteConfig; /// The current application environment (e.g., production, development, demo). final local_config.AppEnvironment? environment; @@ -78,11 +84,11 @@ class AppState extends Equatable { FlexScheme? flexScheme, String? fontFamily, AppTextScaleFactor? appTextScaleFactor, - AppStatus? status, + AppStatus? status, // Changed from AppStatus User? user, UserAppSettings? settings, Locale? locale, - AppConfig? appConfig, + RemoteConfig? remoteConfig, local_config.AppEnvironment? environment, bool clearFontFamily = false, bool clearLocale = false, @@ -100,7 +106,7 @@ class AppState extends Equatable { user: user ?? this.user, settings: settings ?? this.settings, locale: clearLocale ? null : locale ?? this.locale, - appConfig: clearAppConfig ? null : appConfig ?? this.appConfig, + remoteConfig: clearAppConfig ? null : remoteConfig ?? this.remoteConfig, environment: clearEnvironment ? null : environment ?? this.environment, ); } @@ -116,7 +122,7 @@ class AppState extends Equatable { user, settings, locale, - appConfig, + remoteConfig, environment, ]; } diff --git a/lib/shared/services/demo_data_migration_service.dart b/lib/app/services/demo_data_migration_service.dart similarity index 100% rename from lib/shared/services/demo_data_migration_service.dart rename to lib/app/services/demo_data_migration_service.dart diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index d3adeb7e..a37c4817 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -1,6 +1,3 @@ -// -// ignore_for_file: deprecated_member_use - import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -10,51 +7,48 @@ import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_kv_storage_service/ht_kv_storage_service.dart'; import 'package:ht_main/app/bloc/app_bloc.dart'; import 'package:ht_main/app/config/app_environment.dart'; +import 'package:ht_main/app/services/demo_data_migration_service.dart'; import 'package:ht_main/authentication/bloc/authentication_bloc.dart'; import 'package:ht_main/l10n/app_localizations.dart'; -import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/router.dart'; -import 'package:ht_main/shared/services/demo_data_migration_service.dart'; -import 'package:ht_main/shared/theme/app_theme.dart'; -import 'package:ht_main/shared/widgets/failure_state_widget.dart'; -import 'package:ht_main/shared/widgets/loading_state_widget.dart'; -import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_shared/ht_shared.dart' hide AppStatus; +import 'package:ht_ui_kit/ht_ui_kit.dart'; class App extends StatelessWidget { const App({ required HtAuthRepository htAuthenticationRepository, required HtDataRepository htHeadlinesRepository, - required HtDataRepository htCategoriesRepository, + required HtDataRepository htTopicsRepository, required HtDataRepository htCountriesRepository, required HtDataRepository htSourcesRepository, required HtDataRepository htUserAppSettingsRepository, required HtDataRepository htUserContentPreferencesRepository, - required HtDataRepository htAppConfigRepository, + required HtDataRepository htRemoteConfigRepository, required HtKVStorageService kvStorageService, required AppEnvironment environment, this.demoDataMigrationService, super.key, }) : _htAuthenticationRepository = htAuthenticationRepository, _htHeadlinesRepository = htHeadlinesRepository, - _htCategoriesRepository = htCategoriesRepository, + _htTopicsRepository = htTopicsRepository, _htCountriesRepository = htCountriesRepository, _htSourcesRepository = htSourcesRepository, _htUserAppSettingsRepository = htUserAppSettingsRepository, _htUserContentPreferencesRepository = htUserContentPreferencesRepository, - _htAppConfigRepository = htAppConfigRepository, + _htAppConfigRepository = htRemoteConfigRepository, _kvStorageService = kvStorageService, _environment = environment; final HtAuthRepository _htAuthenticationRepository; final HtDataRepository _htHeadlinesRepository; - final HtDataRepository _htCategoriesRepository; + final HtDataRepository _htTopicsRepository; final HtDataRepository _htCountriesRepository; final HtDataRepository _htSourcesRepository; final HtDataRepository _htUserAppSettingsRepository; final HtDataRepository _htUserContentPreferencesRepository; - final HtDataRepository _htAppConfigRepository; + final HtDataRepository _htAppConfigRepository; final HtKVStorageService _kvStorageService; final AppEnvironment _environment; final DemoDataMigrationService? demoDataMigrationService; @@ -65,7 +59,7 @@ class App extends StatelessWidget { providers: [ RepositoryProvider.value(value: _htAuthenticationRepository), RepositoryProvider.value(value: _htHeadlinesRepository), - RepositoryProvider.value(value: _htCategoriesRepository), + RepositoryProvider.value(value: _htTopicsRepository), RepositoryProvider.value(value: _htCountriesRepository), RepositoryProvider.value(value: _htSourcesRepository), RepositoryProvider.value(value: _htUserAppSettingsRepository), @@ -80,7 +74,8 @@ class App extends StatelessWidget { authenticationRepository: context.read(), userAppSettingsRepository: context .read>(), - appConfigRepository: context.read>(), + appConfigRepository: context + .read>(), environment: _environment, demoDataMigrationService: demoDataMigrationService, ), @@ -94,7 +89,7 @@ class App extends StatelessWidget { child: _AppView( htAuthenticationRepository: _htAuthenticationRepository, htHeadlinesRepository: _htHeadlinesRepository, - htCategoriesRepository: _htCategoriesRepository, + htTopicRepository: _htTopicsRepository, htCountriesRepository: _htCountriesRepository, htSourcesRepository: _htSourcesRepository, htUserAppSettingsRepository: _htUserAppSettingsRepository, @@ -112,7 +107,7 @@ class _AppView extends StatefulWidget { const _AppView({ required this.htAuthenticationRepository, required this.htHeadlinesRepository, - required this.htCategoriesRepository, + required this.htTopicRepository, required this.htCountriesRepository, required this.htSourcesRepository, required this.htUserAppSettingsRepository, @@ -123,13 +118,13 @@ class _AppView extends StatefulWidget { final HtAuthRepository htAuthenticationRepository; final HtDataRepository htHeadlinesRepository; - final HtDataRepository htCategoriesRepository; + final HtDataRepository htTopicRepository; final HtDataRepository htCountriesRepository; final HtDataRepository htSourcesRepository; final HtDataRepository htUserAppSettingsRepository; final HtDataRepository htUserContentPreferencesRepository; - final HtDataRepository htAppConfigRepository; + final HtDataRepository htAppConfigRepository; final AppEnvironment environment; @override @@ -152,13 +147,13 @@ class _AppViewState extends State<_AppView> { authStatusNotifier: _statusNotifier, htAuthenticationRepository: widget.htAuthenticationRepository, htHeadlinesRepository: widget.htHeadlinesRepository, - htCategoriesRepository: widget.htCategoriesRepository, + htTopicsRepository: widget.htTopicRepository, htCountriesRepository: widget.htCountriesRepository, htSourcesRepository: widget.htSourcesRepository, htUserAppSettingsRepository: widget.htUserAppSettingsRepository, htUserContentPreferencesRepository: widget.htUserContentPreferencesRepository, - htAppConfigRepository: widget.htAppConfigRepository, + htRemoteConfigRepository: widget.htAppConfigRepository, environment: widget.environment, ); @@ -190,7 +185,7 @@ class _AppViewState extends State<_AppView> { builder: (context, state) { // Defer l10n access until inside a MaterialApp context - // Handle critical AppConfig loading states globally + // Handle critical RemoteConfig loading states globally if (state.status == AppStatus.configFetching) { return MaterialApp( debugShowCheckedModeBanner: false, @@ -208,17 +203,24 @@ class _AppViewState extends State<_AppView> { ), themeMode: state .themeMode, // Still respect light/dark if available from system - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + ...HtUiKitLocalizations.localizationsDelegates, + ], + supportedLocales: const [ + ...AppLocalizations.supportedLocales, + ...HtUiKitLocalizations.supportedLocales, + ], home: Scaffold( body: Builder( // Use Builder to get context under MaterialApp builder: (innerContext) { - final l10n = innerContext.l10n; return LoadingStateWidget( icon: Icons.settings_applications_outlined, - headline: l10n.headlinesFeedLoadingHeadline, - subheadline: l10n.pleaseWait, + headline: AppLocalizations.of( + innerContext, + ).headlinesFeedLoadingHeadline, + subheadline: AppLocalizations.of(innerContext).pleaseWait, ); }, ), @@ -242,16 +244,23 @@ class _AppViewState extends State<_AppView> { fontFamily: null, ), themeMode: state.themeMode, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + ...HtUiKitLocalizations.localizationsDelegates, + ], + supportedLocales: const [ + ...AppLocalizations.supportedLocales, + ...HtUiKitLocalizations.supportedLocales, + ], home: Scaffold( body: Builder( // Use Builder to get context under MaterialApp builder: (innerContext) { - final l10n = innerContext.l10n; return FailureStateWidget( - message: l10n.unknownError, - retryButtonText: 'Retry', + exception: const NetworkException(), + retryButtonText: HtUiKitLocalizations.of( + innerContext, + )!.retryButtonText, onRetry: () { // Use outer context for BLoC access context.read().add( @@ -297,8 +306,14 @@ class _AppViewState extends State<_AppView> { ), routerConfig: _router, locale: state.locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + ...HtUiKitLocalizations.localizationsDelegates, + ], + supportedLocales: const [ + ...AppLocalizations.supportedLocales, + ...HtUiKitLocalizations.supportedLocales, + ], ); }, ), diff --git a/lib/authentication/bloc/authentication_bloc.dart b/lib/authentication/bloc/authentication_bloc.dart index 95656807..99f4207c 100644 --- a/lib/authentication/bloc/authentication_bloc.dart +++ b/lib/authentication/bloc/authentication_bloc.dart @@ -3,15 +3,7 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:ht_auth_repository/ht_auth_repository.dart'; -import 'package:ht_shared/ht_shared.dart' - show - AuthenticationException, - HtHttpException, - InvalidInputException, - NetworkException, - OperationFailedException, - ServerException, - User; +import 'package:ht_shared/ht_shared.dart'; part 'authentication_event.dart'; part 'authentication_state.dart'; @@ -24,7 +16,7 @@ class AuthenticationBloc /// {@macro authentication_bloc} AuthenticationBloc({required HtAuthRepository authenticationRepository}) : _authenticationRepository = authenticationRepository, - super(AuthenticationInitial()) { + super(const AuthenticationState()) { // Listen to authentication state changes from the repository _userAuthSubscription = _authenticationRepository.authStateChanges.listen( (user) => add(_AuthenticationUserChanged(user: user)), @@ -50,9 +42,19 @@ class AuthenticationBloc Emitter emit, ) async { if (event.user != null) { - emit(AuthenticationAuthenticated(user: event.user!)); + emit( + state.copyWith( + status: AuthenticationStatus.authenticated, + user: event.user, + ), + ); } else { - emit(AuthenticationUnauthenticated()); + emit( + state.copyWith( + status: AuthenticationStatus.unauthenticated, + user: null, + ), + ); } } @@ -61,33 +63,24 @@ class AuthenticationBloc AuthenticationRequestSignInCodeRequested event, Emitter emit, ) async { - // Validate email format (basic check) - if (event.email.isEmpty || !event.email.contains('@')) { - emit(const AuthenticationFailure('Please enter a valid email address.')); - return; - } - emit(AuthenticationRequestCodeLoading()); + emit(state.copyWith(status: AuthenticationStatus.requestCodeInProgress)); try { await _authenticationRepository.requestSignInCode(event.email); - emit(AuthenticationCodeSentSuccess(email: event.email)); - } on InvalidInputException catch (e) { - emit(AuthenticationFailure('Invalid input: ${e.message}')); - } on NetworkException catch (_) { - emit(const AuthenticationFailure('Network error occurred.')); - } on ServerException catch (e) { - emit(AuthenticationFailure('Server error: ${e.message}')); - } on OperationFailedException catch (e) { - emit(AuthenticationFailure('Operation failed: ${e.message}')); + emit( + state.copyWith( + status: AuthenticationStatus.requestCodeSuccess, + email: event.email, + ), + ); } on HtHttpException catch (e) { - // Catch any other HtHttpException subtypes - final message = e.message.isNotEmpty - ? e.message - : 'An unspecified HTTP error occurred.'; - emit(AuthenticationFailure('HTTP error: $message')); + emit(state.copyWith(status: AuthenticationStatus.failure, exception: e)); } catch (e) { - // Catch any other unexpected errors - emit(AuthenticationFailure('An unexpected error occurred: $e')); - // Optionally log the stackTrace here + emit( + state.copyWith( + status: AuthenticationStatus.failure, + exception: UnknownException(e.toString()), + ), + ); } } @@ -96,28 +89,20 @@ class AuthenticationBloc AuthenticationVerifyCodeRequested event, Emitter emit, ) async { - emit(AuthenticationLoading()); + emit(state.copyWith(status: AuthenticationStatus.loading)); try { await _authenticationRepository.verifySignInCode(event.email, event.code); // On success, the _AuthenticationUserChanged listener will handle - // emitting AuthenticationAuthenticated. - } on InvalidInputException catch (e) { - emit(AuthenticationFailure(e.message)); - } on AuthenticationException catch (e) { - emit(AuthenticationFailure(e.message)); - } on NetworkException catch (_) { - emit(const AuthenticationFailure('Network error occurred.')); - } on ServerException catch (e) { - emit(AuthenticationFailure('Server error: ${e.message}')); - } on OperationFailedException catch (e) { - emit(AuthenticationFailure('Operation failed: ${e.message}')); + // emitting the authenticated state. } on HtHttpException catch (e) { - // Catch any other HtHttpException subtypes - emit(AuthenticationFailure('HTTP error: ${e.message}')); + emit(state.copyWith(status: AuthenticationStatus.failure, exception: e)); } catch (e) { - // Catch any other unexpected errors - emit(AuthenticationFailure('An unexpected error occurred: $e')); - // Optionally log the stackTrace here + emit( + state.copyWith( + status: AuthenticationStatus.failure, + exception: UnknownException(e.toString()), + ), + ); } } @@ -126,22 +111,20 @@ class AuthenticationBloc AuthenticationAnonymousSignInRequested event, Emitter emit, ) async { - emit(AuthenticationLoading()); + emit(state.copyWith(status: AuthenticationStatus.loading)); try { await _authenticationRepository.signInAnonymously(); // On success, the _AuthenticationUserChanged listener will handle - // emitting AuthenticationAuthenticated. - } on NetworkException catch (_) { - emit(const AuthenticationFailure('Network error occurred.')); - } on ServerException catch (e) { - emit(AuthenticationFailure('Server error: ${e.message}')); - } on OperationFailedException catch (e) { - emit(AuthenticationFailure('Operation failed: ${e.message}')); + // emitting the authenticated state. } on HtHttpException catch (e) { - // Catch any other HtHttpException subtypes - emit(AuthenticationFailure('HTTP error: ${e.message}')); + emit(state.copyWith(status: AuthenticationStatus.failure, exception: e)); } catch (e) { - emit(AuthenticationFailure('An unexpected error occurred: $e')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + exception: UnknownException(e.toString()), + ), + ); } } @@ -150,26 +133,20 @@ class AuthenticationBloc AuthenticationSignOutRequested event, Emitter emit, ) async { - emit(AuthenticationLoading()); + emit(state.copyWith(status: AuthenticationStatus.loading)); try { await _authenticationRepository.signOut(); // On success, the _AuthenticationUserChanged listener will handle - // emitting AuthenticationUnauthenticated. - // No need to emit AuthenticationLoading() before calling signOut if - // the authStateChanges listener handles the subsequent state update. - // However, if immediate feedback is desired, it can be kept. - // For now, let's assume the listener is sufficient. - } on NetworkException catch (_) { - emit(const AuthenticationFailure('Network error occurred.')); - } on ServerException catch (e) { - emit(AuthenticationFailure('Server error: ${e.message}')); - } on OperationFailedException catch (e) { - emit(AuthenticationFailure('Operation failed: ${e.message}')); + // emitting the unauthenticated state. } on HtHttpException catch (e) { - // Catch any other HtHttpException subtypes - emit(AuthenticationFailure('HTTP error: ${e.message}')); + emit(state.copyWith(status: AuthenticationStatus.failure, exception: e)); } catch (e) { - emit(AuthenticationFailure('An unexpected error occurred: $e')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + exception: UnknownException(e.toString()), + ), + ); } } diff --git a/lib/authentication/bloc/authentication_state.dart b/lib/authentication/bloc/authentication_state.dart index 67c16504..1e85f8ba 100644 --- a/lib/authentication/bloc/authentication_state.dart +++ b/lib/authentication/bloc/authentication_state.dart @@ -1,74 +1,73 @@ part of 'authentication_bloc.dart'; -/// {@template authentication_state} -/// Base class for authentication states. -/// {@endtemplate} -sealed class AuthenticationState extends Equatable { - /// {@macro authentication_state} - const AuthenticationState(); +/// Enum representing the different statuses of the authentication process. +enum AuthenticationStatus { + /// The initial state before any authentication has been attempted. + initial, - @override - List get props => []; -} + /// The user is successfully authenticated. + authenticated, -/// {@template authentication_initial} -/// The initial authentication state. -/// {@endtemplate} -final class AuthenticationInitial extends AuthenticationState {} + /// The user is not authenticated. + unauthenticated, -/// {@template authentication_loading} -/// A state indicating that an authentication operation is in progress. -/// {@endtemplate} -final class AuthenticationLoading extends AuthenticationState {} + /// An authentication operation is in progress (e.g., verifying code, signing out). + loading, -/// {@template authentication_authenticated} -/// Represents a successful authentication. -/// {@endtemplate} -final class AuthenticationAuthenticated extends AuthenticationState { - /// {@macro authentication_authenticated} - const AuthenticationAuthenticated({required this.user}); + /// A request to send a sign-in code is in progress. + requestCodeInProgress, - /// The authenticated [User] object. - final User user; + /// The sign-in code has been successfully sent. + requestCodeSuccess, - @override - List get props => [user]; + /// An authentication operation has failed. + failure, } -/// {@template authentication_unauthenticated} -/// Represents an unauthenticated state. -/// {@endtemplate} -final class AuthenticationUnauthenticated extends AuthenticationState {} - -/// {@template authentication_request_code_loading} -/// State indicating that the sign-in code is being requested. +/// {@template authentication_state} +/// Represents the state of the authentication process. +/// +/// This class uses a status enum [AuthenticationStatus] to represent the +/// current state, making state management more predictable. It holds the +/// authenticated user, the email for the code verification flow, and any +/// exception that occurred during a failure. /// {@endtemplate} -final class AuthenticationRequestCodeLoading extends AuthenticationState {} +class AuthenticationState extends Equatable { + /// {@macro authentication_state} + const AuthenticationState({ + this.status = AuthenticationStatus.initial, + this.user, + this.email, + this.exception, + }); -/// {@template authentication_code_sent_success} -/// State indicating that the sign-in code was sent successfully. -/// {@endtemplate} -final class AuthenticationCodeSentSuccess extends AuthenticationState { - /// {@macro authentication_code_sent_success} - const AuthenticationCodeSentSuccess({required this.email}); + /// The current status of the authentication process. + final AuthenticationStatus status; - /// The email address the code was sent to. - final String email; + /// The authenticated user. Null if not authenticated. + final User? user; - @override - List get props => [email]; -} + /// The email address used in the sign-in code flow. + final String? email; -/// {@template authentication_failure} -/// Represents an authentication failure. -/// {@endtemplate} -final class AuthenticationFailure extends AuthenticationState { - /// {@macro authentication_failure} - const AuthenticationFailure(this.errorMessage); + /// The exception that occurred, if any. + final HtHttpException? exception; - /// The error message describing the authentication failure. - final String errorMessage; + /// Creates a copy of the current [AuthenticationState] with updated values. + AuthenticationState copyWith({ + AuthenticationStatus? status, + User? user, + String? email, + HtHttpException? exception, + }) { + return AuthenticationState( + status: status ?? this.status, + user: user ?? this.user, + email: email ?? this.email, + exception: exception ?? this.exception, + ); + } @override - List get props => [errorMessage]; + List get props => [status, user, email, exception]; } diff --git a/lib/authentication/view/authentication_page.dart b/lib/authentication/view/authentication_page.dart index ce1dcc51..e6771e5c 100644 --- a/lib/authentication/view/authentication_page.dart +++ b/lib/authentication/view/authentication_page.dart @@ -7,7 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:ht_main/authentication/bloc/authentication_bloc.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template authentication_page} /// Displays authentication options (Google, Email, Anonymous) based on context. @@ -39,7 +39,7 @@ class AuthenticationPage extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; @@ -61,27 +61,20 @@ class AuthenticationPage extends StatelessWidget { ), body: SafeArea( child: BlocConsumer( - // Listener remains crucial for feedback (errors) listener: (context, state) { - if (state is AuthenticationFailure) { + if (state.status == AuthenticationStatus.failure) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( - content: Text( - // Provide a more user-friendly error message if possible - state.errorMessage, - ), + content: Text(state.exception!.toFriendlyMessage(context)), backgroundColor: colorScheme.error, ), ); } - // Success states (Google/Anonymous) are typically handled by - // the AppBloc listening to repository changes and triggering redirects. - // Email link success is handled in the dedicated email flow pages. }, builder: (context, state) { - final isLoading = state is AuthenticationLoading; + final isLoading = state.status == AuthenticationStatus.loading; return Padding( padding: const EdgeInsets.all(AppSpacing.paddingLarge), @@ -161,8 +154,7 @@ class AuthenticationPage extends StatelessWidget { ], // --- Loading Indicator --- - if (isLoading && - state is! AuthenticationRequestCodeLoading) ...[ + if (isLoading) ...[ const Padding( padding: EdgeInsets.only(top: AppSpacing.xl), child: Center(child: CircularProgressIndicator()), diff --git a/lib/authentication/view/email_code_verification_page.dart b/lib/authentication/view/email_code_verification_page.dart index 3fc8a59a..90e69bd2 100644 --- a/lib/authentication/view/email_code_verification_page.dart +++ b/lib/authentication/view/email_code_verification_page.dart @@ -5,7 +5,7 @@ import 'package:ht_main/app/bloc/app_bloc.dart'; import 'package:ht_main/app/config/config.dart'; import 'package:ht_main/authentication/bloc/authentication_bloc.dart'; import 'package:ht_main/l10n/l10n.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template email_code_verification_page} /// Page where the user enters the 6-digit code sent to their email @@ -20,7 +20,7 @@ class EmailCodeVerificationPage extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; @@ -29,12 +29,12 @@ class EmailCodeVerificationPage extends StatelessWidget { body: SafeArea( child: BlocConsumer( listener: (context, state) { - if (state is AuthenticationFailure) { + if (state.status == AuthenticationStatus.failure) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( - content: Text(state.errorMessage), + content: Text(state.exception!.toFriendlyMessage(context)), backgroundColor: colorScheme.error, ), ); @@ -42,7 +42,7 @@ class EmailCodeVerificationPage extends StatelessWidget { // Successful authentication is handled by AppBloc redirecting. }, builder: (context, state) { - final isLoading = state is AuthenticationLoading; + final isLoading = state.status == AuthenticationStatus.loading; return Padding( padding: const EdgeInsets.all(AppSpacing.paddingLarge), @@ -150,7 +150,7 @@ class _EmailCodeVerificationFormState @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final textTheme = Theme.of(context).textTheme; return Form( diff --git a/lib/authentication/view/request_code_page.dart b/lib/authentication/view/request_code_page.dart index c19973b2..64a589f2 100644 --- a/lib/authentication/view/request_code_page.dart +++ b/lib/authentication/view/request_code_page.dart @@ -7,7 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:ht_main/authentication/bloc/authentication_bloc.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template request_code_page} /// Page for initiating the email code sign-in process. @@ -37,7 +37,7 @@ class _RequestCodeView extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; @@ -68,32 +68,36 @@ class _RequestCodeView extends StatelessWidget { body: SafeArea( child: BlocConsumer( listener: (context, state) { - if (state is AuthenticationFailure) { + if (state.status == AuthenticationStatus.failure) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( - content: Text(state.errorMessage), + content: Text(state.exception!.toFriendlyMessage(context)), backgroundColor: colorScheme.error, ), ); - } else if (state is AuthenticationCodeSentSuccess) { + } else if (state.status == + AuthenticationStatus.requestCodeSuccess) { // Navigate to the code verification page on success, passing the email context.goNamed( isLinkingContext ? Routes.linkingVerifyCodeName : Routes.verifyCodeName, - pathParameters: {'email': state.email}, + pathParameters: {'email': state.email!}, ); } }, // BuildWhen prevents unnecessary rebuilds if only listening buildWhen: (previous, current) => - current is AuthenticationInitial || - current is AuthenticationRequestCodeLoading || - current is AuthenticationFailure, + current.status != previous.status && + (current.status == AuthenticationStatus.initial || + current.status == + AuthenticationStatus.requestCodeInProgress || + current.status == AuthenticationStatus.failure), builder: (context, state) { - final isLoading = state is AuthenticationRequestCodeLoading; + final isLoading = + state.status == AuthenticationStatus.requestCodeInProgress; return Padding( padding: const EdgeInsets.all(AppSpacing.paddingLarge), @@ -176,7 +180,7 @@ class _EmailLinkFormState extends State<_EmailLinkForm> { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 1e12a35c..cbc7f2b3 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -13,12 +13,10 @@ import 'package:ht_http_client/ht_http_client.dart'; import 'package:ht_kv_storage_shared_preferences/ht_kv_storage_shared_preferences.dart'; import 'package:ht_main/app/app.dart'; import 'package:ht_main/app/config/config.dart' as app_config; +import 'package:ht_main/app/services/demo_data_migration_service.dart'; import 'package:ht_main/bloc_observer.dart'; -import 'package:ht_main/shared/localization/ar_timeago_messages.dart'; -import 'package:ht_main/shared/localization/en_timeago_messages.dart'; -import 'package:ht_main/shared/services/demo_data_migration_service.dart'; import 'package:ht_shared/ht_shared.dart'; -import 'package:timeago/timeago.dart' as timeago; +import 'package:logging/logging.dart'; Future bootstrap( app_config.AppConfig appConfig, @@ -27,8 +25,7 @@ Future bootstrap( WidgetsFlutterBinding.ensureInitialized(); Bloc.observer = const AppBlocObserver(); - timeago.setLocaleMessages('en', EnTimeagoMessages()); - timeago.setLocaleMessages('ar', ArTimeagoMessages()); + final logger = Logger('bootstrap'); final kvStorage = await HtKvStorageSharedPreferences.getInstance(); @@ -48,6 +45,7 @@ Future bootstrap( baseUrl: appConfig.baseUrl, tokenProvider: () => authenticationRepository.getAuthToken(), isWeb: kIsWeb, + logger: logger, ); authClient = HtAuthApi(httpClient: httpClient); authenticationRepository = HtAuthRepository( @@ -58,46 +56,53 @@ Future bootstrap( // Conditional data client instantiation based on environment HtDataClient headlinesClient; - HtDataClient categoriesClient; + HtDataClient topicsClient; HtDataClient countriesClient; HtDataClient sourcesClient; HtDataClient userContentPreferencesClient; HtDataClient userAppSettingsClient; - HtDataClient appConfigClient; + HtDataClient remoteConfigClient; if (appConfig.environment == app_config.AppEnvironment.demo) { headlinesClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: headlinesFixturesData.map(Headline.fromJson).toList(), + initialData: headlinesFixturesData, + logger: logger, ); - categoriesClient = HtDataInMemory( + topicsClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: categoriesFixturesData.map(Category.fromJson).toList(), + initialData: topicsFixturesData, + logger: logger, ); countriesClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: countriesFixturesData.map(Country.fromJson).toList(), + initialData: countriesFixturesData, + logger: logger, ); sourcesClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: sourcesFixturesData.map(Source.fromJson).toList(), + initialData: sourcesFixturesData, + logger: logger, ); userContentPreferencesClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, + logger: logger, ); userAppSettingsClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, + logger: logger, ); - appConfigClient = HtDataInMemory( + remoteConfigClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: [AppConfig.fromJson(appConfigFixtureData)], + initialData: remoteConfigsFixturesData, + logger: logger, ); } else if (appConfig.environment == app_config.AppEnvironment.development) { headlinesClient = HtDataApi( @@ -105,42 +110,49 @@ Future bootstrap( modelName: 'headline', fromJson: Headline.fromJson, toJson: (headline) => headline.toJson(), + logger: logger, ); - categoriesClient = HtDataApi( + topicsClient = HtDataApi( httpClient: httpClient, - modelName: 'category', - fromJson: Category.fromJson, - toJson: (category) => category.toJson(), + modelName: 'topic', + fromJson: Topic.fromJson, + toJson: (topic) => topic.toJson(), + logger: logger, ); countriesClient = HtDataApi( httpClient: httpClient, modelName: 'country', fromJson: Country.fromJson, toJson: (country) => country.toJson(), + logger: logger, ); sourcesClient = HtDataApi( httpClient: httpClient, modelName: 'source', fromJson: Source.fromJson, toJson: (source) => source.toJson(), + logger: logger, ); userContentPreferencesClient = HtDataApi( httpClient: httpClient, modelName: 'user_content_preferences', fromJson: UserContentPreferences.fromJson, toJson: (prefs) => prefs.toJson(), + logger: logger, ); userAppSettingsClient = HtDataApi( httpClient: httpClient, modelName: 'user_app_settings', fromJson: UserAppSettings.fromJson, toJson: (settings) => settings.toJson(), + logger: logger, ); - appConfigClient = HtDataApi( + remoteConfigClient = HtDataApi( httpClient: httpClient, - modelName: 'app_config', - fromJson: AppConfig.fromJson, + modelName: 'remote_config', + fromJson: RemoteConfig.fromJson, toJson: (config) => config.toJson(), + logger: logger, ); } else { // Default to API clients for production @@ -149,51 +161,56 @@ Future bootstrap( modelName: 'headline', fromJson: Headline.fromJson, toJson: (headline) => headline.toJson(), + logger: logger, ); - categoriesClient = HtDataApi( + topicsClient = HtDataApi( httpClient: httpClient, - modelName: 'category', - fromJson: Category.fromJson, - toJson: (category) => category.toJson(), + modelName: 'topic', + fromJson: Topic.fromJson, + toJson: (topic) => topic.toJson(), + logger: logger, ); countriesClient = HtDataApi( httpClient: httpClient, modelName: 'country', fromJson: Country.fromJson, toJson: (country) => country.toJson(), + logger: logger, ); sourcesClient = HtDataApi( httpClient: httpClient, modelName: 'source', fromJson: Source.fromJson, toJson: (source) => source.toJson(), + logger: logger, ); userContentPreferencesClient = HtDataApi( httpClient: httpClient, modelName: 'user_content_preferences', fromJson: UserContentPreferences.fromJson, toJson: (prefs) => prefs.toJson(), + logger: logger, ); userAppSettingsClient = HtDataApi( httpClient: httpClient, modelName: 'user_app_settings', fromJson: UserAppSettings.fromJson, toJson: (settings) => settings.toJson(), + logger: logger, ); - appConfigClient = HtDataApi( + remoteConfigClient = HtDataApi( httpClient: httpClient, - modelName: 'app_config', - fromJson: AppConfig.fromJson, + modelName: 'remote_config', + fromJson: RemoteConfig.fromJson, toJson: (config) => config.toJson(), + logger: logger, ); } final headlinesRepository = HtDataRepository( dataClient: headlinesClient, ); - final categoriesRepository = HtDataRepository( - dataClient: categoriesClient, - ); + final topicsRepository = HtDataRepository(dataClient: topicsClient); final countriesRepository = HtDataRepository( dataClient: countriesClient, ); @@ -205,8 +222,8 @@ Future bootstrap( final userAppSettingsRepository = HtDataRepository( dataClient: userAppSettingsClient, ); - final appConfigRepository = HtDataRepository( - dataClient: appConfigClient, + final remoteConfigRepository = HtDataRepository( + dataClient: remoteConfigClient, ); // Conditionally instantiate DemoDataMigrationService @@ -221,12 +238,12 @@ Future bootstrap( return App( htAuthenticationRepository: authenticationRepository, htHeadlinesRepository: headlinesRepository, - htCategoriesRepository: categoriesRepository, + htTopicsRepository: topicsRepository, htCountriesRepository: countriesRepository, htSourcesRepository: sourcesRepository, htUserAppSettingsRepository: userAppSettingsRepository, htUserContentPreferencesRepository: userContentPreferencesRepository, - htAppConfigRepository: appConfigRepository, + htRemoteConfigRepository: remoteConfigRepository, kvStorageService: kvStorage, environment: environment, demoDataMigrationService: demoDataMigrationService, diff --git a/lib/entity_details/bloc/entity_details_bloc.dart b/lib/entity_details/bloc/entity_details_bloc.dart index d259dfd1..6208ca9e 100644 --- a/lib/entity_details/bloc/entity_details_bloc.dart +++ b/lib/entity_details/bloc/entity_details_bloc.dart @@ -7,7 +7,6 @@ import 'package:equatable/equatable.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_main/account/bloc/account_bloc.dart'; import 'package:ht_main/app/bloc/app_bloc.dart'; -import 'package:ht_main/entity_details/models/entity_type.dart'; import 'package:ht_main/shared/services/feed_injector_service.dart'; import 'package:ht_shared/ht_shared.dart'; @@ -17,13 +16,13 @@ part 'entity_details_state.dart'; class EntityDetailsBloc extends Bloc { EntityDetailsBloc({ required HtDataRepository headlinesRepository, - required HtDataRepository categoryRepository, + required HtDataRepository topicRepository, required HtDataRepository sourceRepository, required AccountBloc accountBloc, required AppBloc appBloc, required FeedInjectorService feedInjectorService, }) : _headlinesRepository = headlinesRepository, - _categoryRepository = categoryRepository, + _topicRepository = topicRepository, _sourceRepository = sourceRepository, _accountBloc = accountBloc, _appBloc = appBloc, @@ -49,7 +48,7 @@ class EntityDetailsBloc extends Bloc { } final HtDataRepository _headlinesRepository; - final HtDataRepository _categoryRepository; + final HtDataRepository _topicRepository; final HtDataRepository _sourceRepository; final AccountBloc _accountBloc; final AppBloc _appBloc; @@ -66,132 +65,103 @@ class EntityDetailsBloc extends Bloc { state.copyWith(status: EntityDetailsStatus.loading, clearEntity: true), ); - dynamic entityToLoad = event.entity; - var entityTypeToLoad = event.entityType; - try { // 1. Determine/Fetch Entity - if (entityToLoad == null && - event.entityId != null && - event.entityType != null) { - entityTypeToLoad = event.entityType; - if (event.entityType == EntityType.category) { - entityToLoad = await _categoryRepository.read(id: event.entityId!); - } else if (event.entityType == EntityType.source) { - entityToLoad = await _sourceRepository.read(id: event.entityId!); - } else { - throw Exception('Unknown entity type for ID fetch'); - } - } else if (entityToLoad != null) { - // If entity is directly provided, determine its type - if (entityToLoad is Category) { - entityTypeToLoad = EntityType.category; - } else if (entityToLoad is Source) { - entityTypeToLoad = EntityType.source; + FeedItem entityToLoad; + ContentType contentTypeToLoad; + + if (event.entity != null) { + entityToLoad = event.entity!; + contentTypeToLoad = event.entity is Topic + ? ContentType.topic + : ContentType.source; + } else { + contentTypeToLoad = event.contentType!; + if (contentTypeToLoad == ContentType.topic) { + entityToLoad = await _topicRepository.read(id: event.entityId!); } else { - throw Exception('Provided entity is of unknown type'); + entityToLoad = await _sourceRepository.read(id: event.entityId!); } } - if (entityToLoad == null || entityTypeToLoad == null) { - emit( - state.copyWith( - status: EntityDetailsStatus.failure, - errorMessage: 'Entity could not be determined or loaded.', - ), - ); - return; - } - // 2. Fetch Initial Headlines - final queryParams = {}; - if (entityTypeToLoad == EntityType.category) { - queryParams['categories'] = (entityToLoad as Category).id; - } else if (entityTypeToLoad == EntityType.source) { - queryParams['sources'] = (entityToLoad as Source).id; + final filter = {}; + if (contentTypeToLoad == ContentType.topic) { + filter['topic.id'] = (entityToLoad as Topic).id; + } else { + filter['source.id'] = (entityToLoad as Source).id; } - final headlineResponse = await _headlinesRepository.readAllByQuery( - queryParams, - limit: _headlinesLimit, + final headlineResponse = await _headlinesRepository.readAll( + filter: filter, + pagination: const PaginationOptions(limit: _headlinesLimit), ); final currentUser = _appBloc.state.user; - final appConfig = _appBloc.state.appConfig; - - if (appConfig == null) { - emit( - state.copyWith( - status: EntityDetailsStatus.failure, - errorMessage: 'App configuration not available.', - entityType: entityTypeToLoad, - entity: entityToLoad, - ), + final remoteConfig = _appBloc.state.remoteConfig; + + if (remoteConfig == null) { + throw const OperationFailedException( + 'App configuration not available.', ); - return; } final processedFeedItems = _feedInjectorService.injectItems( headlines: headlineResponse.items, user: currentUser, - appConfig: appConfig, + remoteConfig: remoteConfig, currentFeedItemCount: 0, ); // 3. Determine isFollowing status var isCurrentlyFollowing = false; - final currentAccountState = _accountBloc.state; - if (currentAccountState.preferences != null) { - if (entityTypeToLoad == EntityType.category && - entityToLoad is Category) { - isCurrentlyFollowing = currentAccountState - .preferences! - .followedCategories - .any((cat) => cat.id == entityToLoad.id); - } else if (entityTypeToLoad == EntityType.source && - entityToLoad is Source) { - isCurrentlyFollowing = currentAccountState - .preferences! - .followedSources - .any((src) => src.id == entityToLoad.id); + final preferences = _accountBloc.state.preferences; + if (preferences != null) { + if (entityToLoad is Topic) { + isCurrentlyFollowing = preferences.followedTopics.any( + (t) => t.id == (entityToLoad as Topic).id, + ); + } else if (entityToLoad is Source) { + isCurrentlyFollowing = preferences.followedSources.any( + (s) => s.id == (entityToLoad as Source).id, + ); } } emit( state.copyWith( status: EntityDetailsStatus.success, - entityType: entityTypeToLoad, + contentType: contentTypeToLoad, entity: entityToLoad, isFollowing: isCurrentlyFollowing, feedItems: processedFeedItems, - headlinesStatus: EntityHeadlinesStatus.success, hasMoreHeadlines: headlineResponse.hasMore, headlinesCursor: headlineResponse.cursor, - clearErrorMessage: true, + clearException: true, ), ); // Dispatch event if AccountAction was injected in the initial load - if (processedFeedItems.any((item) => item is AccountAction) && + if (processedFeedItems.any((item) => item is FeedAction) && _appBloc.state.user?.id != null) { _appBloc.add( - AppUserAccountActionShown(userId: _appBloc.state.user!.id), + AppUserAccountActionShown( + userId: _appBloc.state.user!.id, + feedActionType: + (processedFeedItems.firstWhere((item) => item is FeedAction) + as FeedAction) + .feedActionType, + isCompleted: false, + ), ); } } on HtHttpException catch (e) { - emit( - state.copyWith( - status: EntityDetailsStatus.failure, - errorMessage: e.message, - entityType: entityTypeToLoad, - ), - ); + emit(state.copyWith(status: EntityDetailsStatus.failure, exception: e)); } catch (e) { emit( state.copyWith( status: EntityDetailsStatus.failure, - errorMessage: 'An unexpected error occurred: $e', - entityType: entityTypeToLoad, + exception: UnknownException(e.toString()), ), ); } @@ -201,128 +171,95 @@ class EntityDetailsBloc extends Bloc { EntityDetailsToggleFollowRequested event, Emitter emit, ) async { - if (state.entity == null || state.entityType == null) { - // Cannot toggle follow if no entity is loaded - emit( - state.copyWith( - errorMessage: 'No entity loaded to follow/unfollow.', - clearErrorMessage: false, - ), - ); - return; - } - - // Optimistic update of UI can be handled by listening to AccountBloc state changes - // which will trigger _onEntityDetailsUserPreferencesChanged. + final entity = state.entity; + if (entity == null) return; - if (state.entityType == EntityType.category && state.entity is Category) { - _accountBloc.add( - AccountFollowCategoryToggled(category: state.entity as Category), - ); - } else if (state.entityType == EntityType.source && - state.entity is Source) { - _accountBloc.add( - AccountFollowSourceToggled(source: state.entity as Source), - ); - } else { - // Should not happen if entity and entityType are consistent - emit( - state.copyWith( - errorMessage: 'Cannot determine entity type to follow/unfollow.', - clearErrorMessage: false, - ), - ); + if (entity is Topic) { + _accountBloc.add(AccountFollowTopicToggled(topic: entity)); + } else if (entity is Source) { + _accountBloc.add(AccountFollowSourceToggled(source: entity)); } - // Note: We don't emit a new state here for `isFollowing` directly. - // The change will propagate from AccountBloc -> _accountBlocSubscription - // -> _EntityDetailsUserPreferencesChanged -> update state.isFollowing. - // This keeps AccountBloc as the source of truth for preferences. } Future _onEntityDetailsLoadMoreHeadlinesRequested( EntityDetailsLoadMoreHeadlinesRequested event, Emitter emit, ) async { - if (!state - .hasMoreHeadlines || // Still refers to original headlines pagination - state.headlinesStatus == EntityHeadlinesStatus.loadingMore) { + if (!state.hasMoreHeadlines || + state.status == EntityDetailsStatus.loadingMore) { return; } - if (state.entity == null || state.entityType == null) return; + if (state.entity == null) return; - emit(state.copyWith(headlinesStatus: EntityHeadlinesStatus.loadingMore)); + emit(state.copyWith(status: EntityDetailsStatus.loadingMore)); try { - final queryParams = {}; - if (state.entityType == EntityType.category) { - queryParams['categories'] = (state.entity as Category).id; - } else if (state.entityType == EntityType.source) { - queryParams['sources'] = (state.entity as Source).id; - } else { - emit( - state.copyWith( - headlinesStatus: EntityHeadlinesStatus.failure, - errorMessage: 'Cannot load more headlines: Unknown entity type.', - ), - ); - return; + final filter = {}; + if (state.entity is Topic) { + filter['topic.id'] = (state.entity! as Topic).id; + } else if (state.entity is Source) { + filter['source.id'] = (state.entity! as Source).id; } - final headlineResponse = await _headlinesRepository.readAllByQuery( - queryParams, - limit: _headlinesLimit, - startAfterId: state.headlinesCursor, + final headlineResponse = await _headlinesRepository.readAll( + filter: filter, + pagination: PaginationOptions( + limit: _headlinesLimit, + cursor: state.headlinesCursor, + ), ); final currentUser = _appBloc.state.user; - final appConfig = _appBloc.state.appConfig; + final remoteConfig = _appBloc.state.remoteConfig; - if (appConfig == null) { - emit( - state.copyWith( - headlinesStatus: EntityHeadlinesStatus.failure, - errorMessage: 'App configuration not available for pagination.', - ), + if (remoteConfig == null) { + throw const OperationFailedException( + 'App configuration not available for pagination.', ); - return; } final newProcessedFeedItems = _feedInjectorService.injectItems( headlines: headlineResponse.items, user: currentUser, - appConfig: appConfig, + remoteConfig: remoteConfig, currentFeedItemCount: state.feedItems.length, ); emit( state.copyWith( + status: EntityDetailsStatus.success, feedItems: List.of(state.feedItems)..addAll(newProcessedFeedItems), - headlinesStatus: EntityHeadlinesStatus.success, hasMoreHeadlines: headlineResponse.hasMore, headlinesCursor: headlineResponse.cursor, clearHeadlinesCursor: !headlineResponse.hasMore, ), ); - // Dispatch event if AccountAction was injected in the newly loaded items - if (newProcessedFeedItems.any((item) => item is AccountAction) && + if (newProcessedFeedItems.any((item) => item is FeedAction) && _appBloc.state.user?.id != null) { _appBloc.add( - AppUserAccountActionShown(userId: _appBloc.state.user!.id), + AppUserAccountActionShown( + userId: _appBloc.state.user!.id, + feedActionType: + (newProcessedFeedItems.firstWhere((item) => item is FeedAction) + as FeedAction) + .feedActionType, + isCompleted: false, + ), ); } } on HtHttpException catch (e) { emit( state.copyWith( - headlinesStatus: EntityHeadlinesStatus.failure, - errorMessage: e.message, + status: EntityDetailsStatus.partialFailure, + exception: e, ), ); } catch (e) { emit( state.copyWith( - headlinesStatus: EntityHeadlinesStatus.failure, - errorMessage: 'An unexpected error occurred: $e', + status: EntityDetailsStatus.partialFailure, + exception: UnknownException(e.toString()), ), ); } @@ -332,21 +269,19 @@ class EntityDetailsBloc extends Bloc { _EntityDetailsUserPreferencesChanged event, Emitter emit, ) { - if (state.entity == null || state.entityType == null) return; + final entity = state.entity; + if (entity == null) return; var isCurrentlyFollowing = false; final preferences = event.preferences; - if (state.entityType == EntityType.category && state.entity is Category) { - final currentCategory = state.entity as Category; - isCurrentlyFollowing = preferences.followedCategories.any( - (cat) => cat.id == currentCategory.id, + if (entity is Topic) { + isCurrentlyFollowing = preferences.followedTopics.any( + (t) => t.id == entity.id, ); - } else if (state.entityType == EntityType.source && - state.entity is Source) { - final currentSource = state.entity as Source; + } else if (entity is Source) { isCurrentlyFollowing = preferences.followedSources.any( - (src) => src.id == currentSource.id, + (s) => s.id == entity.id, ); } diff --git a/lib/entity_details/bloc/entity_details_event.dart b/lib/entity_details/bloc/entity_details_event.dart index 36438d7d..b8f02f8a 100644 --- a/lib/entity_details/bloc/entity_details_event.dart +++ b/lib/entity_details/bloc/entity_details_event.dart @@ -1,5 +1,6 @@ part of 'entity_details_bloc.dart'; +/// Base class for all events in the entity details feature. abstract class EntityDetailsEvent extends Equatable { const EntityDetailsEvent(); @@ -7,40 +8,49 @@ abstract class EntityDetailsEvent extends Equatable { List get props => []; } -/// Event to load entity details and initial headlines. -/// Can be triggered by passing an ID and type, or the full entity. +/// Event to load entity details and its initial list of headlines. +/// +/// This can be triggered by providing either a direct [entity] object +/// or an [entityId] and its corresponding [contentType]. class EntityDetailsLoadRequested extends EntityDetailsEvent { const EntityDetailsLoadRequested({ this.entityId, - this.entityType, + this.contentType, this.entity, }) : assert( - (entityId != null && entityType != null) || entity != null, - 'Either entityId/entityType or entity must be provided.', + (entityId != null && contentType != null) || entity != null, + 'Either entityId/contentType or a full entity object must be provided.', ); + /// The unique ID of the entity to load. final String? entityId; - final EntityType? entityType; - final dynamic entity; + + /// The type of the entity to load. + final ContentType? contentType; + + /// The full entity object, if already available. + final FeedItem? entity; @override - List get props => [entityId, entityType, entity]; + List get props => [entityId, contentType, entity]; } -/// Event to toggle the follow status of the current entity. +/// Event to toggle the "follow" status of the currently loaded entity. class EntityDetailsToggleFollowRequested extends EntityDetailsEvent { const EntityDetailsToggleFollowRequested(); } -/// Event to load more headlines for pagination. +/// Event to load the next page of headlines for the current entity. class EntityDetailsLoadMoreHeadlinesRequested extends EntityDetailsEvent { const EntityDetailsLoadMoreHeadlinesRequested(); } -/// Internal event to notify the BLoC that user preferences have changed. +/// 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 diff --git a/lib/entity_details/bloc/entity_details_state.dart b/lib/entity_details/bloc/entity_details_state.dart index 517e2347..9fe3ff83 100644 --- a/lib/entity_details/bloc/entity_details_state.dart +++ b/lib/entity_details/bloc/entity_details_state.dart @@ -1,76 +1,107 @@ part of 'entity_details_bloc.dart'; /// Status for the overall entity details page. -enum EntityDetailsStatus { initial, loading, success, failure } +enum EntityDetailsStatus { + /// The initial state. + initial, -/// Status for fetching headlines within the entity details page. -enum EntityHeadlinesStatus { initial, loadingMore, success, failure } + /// The entity and its initial headlines are being loaded. + loading, + /// The entity and headlines have been successfully loaded. + success, + + /// More headlines are being loaded for pagination. + loadingMore, + + /// The page failed to load the entity or initial headlines. + failure, + + /// A subsequent operation (like pagination) failed. + /// The UI can still display existing data. + partialFailure, +} + +/// {@template entity_details_state} +/// The state for the entity details feature. +/// +/// Contains the loaded entity, its associated feed items, and the +/// current status of data fetching operations. +/// {@endtemplate} class EntityDetailsState extends Equatable { + /// {@macro entity_details_state} const EntityDetailsState({ this.status = EntityDetailsStatus.initial, - this.entityType, + this.contentType, this.entity, this.isFollowing = false, this.feedItems = const [], - this.headlinesStatus = EntityHeadlinesStatus.initial, this.hasMoreHeadlines = true, this.headlinesCursor, - this.errorMessage, + this.exception, }); + /// The overall status of the page. final EntityDetailsStatus status; - final EntityType? entityType; - final dynamic entity; + + /// The type of the entity being displayed (e.g., topic, source). + final ContentType? contentType; + + /// The entity being displayed (e.g., a [Topic] or [Source] object). + final FeedItem? entity; + + /// Whether the current user is following the displayed entity. final bool isFollowing; + + /// The list of feed items (headlines, ads, etc.) to display. final List feedItems; - final EntityHeadlinesStatus headlinesStatus; + + /// Whether there are more headlines to fetch for pagination. final bool hasMoreHeadlines; + + /// The cursor for paginating through headlines. final String? headlinesCursor; - final String? errorMessage; + /// The exception that occurred, if any. + final HtHttpException? exception; + + /// Creates a copy of the current state with updated values. EntityDetailsState copyWith({ EntityDetailsStatus? status, - EntityType? entityType, - dynamic entity, + ContentType? contentType, + FeedItem? entity, bool? isFollowing, List? feedItems, - EntityHeadlinesStatus? headlinesStatus, bool? hasMoreHeadlines, String? headlinesCursor, - String? errorMessage, - bool clearErrorMessage = false, + HtHttpException? exception, bool clearEntity = false, bool clearHeadlinesCursor = false, + bool clearException = false, }) { return EntityDetailsState( status: status ?? this.status, - entityType: entityType ?? this.entityType, + contentType: contentType ?? this.contentType, entity: clearEntity ? null : entity ?? this.entity, isFollowing: isFollowing ?? this.isFollowing, feedItems: feedItems ?? this.feedItems, - headlinesStatus: headlinesStatus ?? this.headlinesStatus, hasMoreHeadlines: hasMoreHeadlines ?? this.hasMoreHeadlines, - headlinesCursor: // This cursor is for fetching original headlines - clearHeadlinesCursor + headlinesCursor: clearHeadlinesCursor ? null : headlinesCursor ?? this.headlinesCursor, - errorMessage: clearErrorMessage - ? null - : errorMessage ?? this.errorMessage, + exception: clearException ? null : exception ?? this.exception, ); } @override List get props => [ status, - entityType, + contentType, entity, isFollowing, feedItems, - headlinesStatus, hasMoreHeadlines, headlinesCursor, - errorMessage, + exception, ]; } diff --git a/lib/entity_details/models/entity_type.dart b/lib/entity_details/models/entity_type.dart deleted file mode 100644 index 4358954b..00000000 --- a/lib/entity_details/models/entity_type.dart +++ /dev/null @@ -1,8 +0,0 @@ -/// Defines the type of entity being displayed or interacted with. -enum EntityType { - /// Represents a news category. - category, - - /// Represents a news source. - source, -} diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index 7d790768..a7617491 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -1,3 +1,5 @@ +// ignore_for_file: no_default_cases + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -5,28 +7,26 @@ import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_main/account/bloc/account_bloc.dart'; import 'package:ht_main/app/bloc/app_bloc.dart'; import 'package:ht_main/entity_details/bloc/entity_details_bloc.dart'; -import 'package:ht_main/entity_details/models/entity_type.dart'; import 'package:ht_main/l10n/app_localizations.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; -import 'package:ht_main/shared/services/feed_injector_service.dart'; -import 'package:ht_main/shared/widgets/widgets.dart'; +import 'package:ht_main/shared/shared.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; class EntityDetailsPageArguments { const EntityDetailsPageArguments({ this.entityId, - this.entityType, + this.contentType, this.entity, }) : assert( - (entityId != null && entityType != null) || entity != null, - 'Either entityId/entityType or entity must be provided.', + (entityId != null && contentType != null) || entity != null, + 'Either entityId/contentType or a full entity object must be provided.', ); final String? entityId; - final EntityType? entityType; - final dynamic entity; + final ContentType? contentType; + final FeedItem? entity; } class EntityDetailsPage extends StatelessWidget { @@ -49,7 +49,7 @@ class EntityDetailsPage extends StatelessWidget { final entityDetailsBloc = EntityDetailsBloc( headlinesRepository: context.read>(), - categoryRepository: context.read>(), + topicRepository: context.read>(), sourceRepository: context.read>(), accountBloc: context.read(), appBloc: context.read(), @@ -57,7 +57,7 @@ class EntityDetailsPage extends StatelessWidget { )..add( EntityDetailsLoadRequested( entityId: args.entityId, - entityType: args.entityType, + contentType: args.contentType, entity: args.entity, ), ); @@ -110,16 +110,17 @@ class _EntityDetailsViewState extends State { return currentScroll >= (maxScroll * 0.9); } - String _getEntityTypeDisplayName(EntityType? type, AppLocalizations l10n) { + String _getContentTypeDisplayName(ContentType? type, AppLocalizations l10n) { if (type == null) return l10n.detailsPageTitle; String name; switch (type) { - case EntityType.category: - name = l10n.entityDetailsCategoryTitle; - case EntityType.source: + case ContentType.topic: + name = l10n.entityDetailsTopicTitle; + case ContentType.source: name = l10n.entityDetailsSourceTitle; + default: + name = l10n.detailsPageTitle; } - // Manual capitalization return name.isNotEmpty ? '${name[0].toUpperCase()}${name.substring(1)}' : name; @@ -127,7 +128,7 @@ class _EntityDetailsViewState extends State { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; @@ -135,8 +136,8 @@ class _EntityDetailsViewState extends State { return Scaffold( body: BlocBuilder( builder: (context, state) { - final entityTypeDisplayNameForTitle = _getEntityTypeDisplayName( - widget.args.entityType, + final entityTypeDisplayNameForTitle = _getContentTypeDisplayName( + state.contentType, l10n, ); @@ -153,13 +154,11 @@ class _EntityDetailsViewState extends State { if (state.status == EntityDetailsStatus.failure && state.entity == null) { return FailureStateWidget( - //TODO(fulleni): add entityDetailsErrorLoadingto l10n - // message: state.errorMessage ?? l10n.entityDetailsErrorLoading(entityType: entityTypeDisplayNameForTitle), - message: state.errorMessage ?? '...', + exception: state.exception!, onRetry: () => context.read().add( EntityDetailsLoadRequested( entityId: widget.args.entityId, - entityType: widget.args.entityType, + contentType: widget.args.contentType, entity: widget.args.entity, ), ), @@ -168,31 +167,27 @@ class _EntityDetailsViewState extends State { final String appBarTitleText; IconData? appBarIconData; - // String? entityImageHeroTag; - if (state.entity is Category) { - final cat = state.entity as Category; - appBarTitleText = cat.name; + if (state.entity is Topic) { + final topic = state.entity! as Topic; + appBarTitleText = topic.name; appBarIconData = Icons.category_outlined; - // entityImageHeroTag = 'category-image-${cat.id}'; } else if (state.entity is Source) { - final src = state.entity as Source; + final src = state.entity! as Source; appBarTitleText = src.name; appBarIconData = Icons.source_outlined; } else { appBarTitleText = l10n.detailsPageTitle; } - final description = state.entity is Category - ? (state.entity as Category).description + final description = state.entity is Topic + ? (state.entity! as Topic).description : state.entity is Source - ? (state.entity as Source).description + ? (state.entity! as Source).description : null; - final entityIconUrl = - (state.entity is Category && - (state.entity as Category).iconUrl != null) - ? (state.entity as Category).iconUrl + final entityIconUrl = (state.entity is Topic) + ? (state.entity! as Topic).iconUrl : null; final followButton = IconButton( @@ -278,8 +273,7 @@ class _EntityDetailsViewState extends State { const SizedBox(height: AppSpacing.lg), ], if (state.feedItems.isNotEmpty || - state.headlinesStatus == - EntityHeadlinesStatus.loadingMore) ...[ + state.status == EntityDetailsStatus.loadingMore) ...[ Text( l10n.headlinesSectionTitle, style: textTheme.titleLarge?.copyWith( @@ -292,8 +286,8 @@ class _EntityDetailsViewState extends State { ), ), if (state.feedItems.isEmpty && - state.headlinesStatus != EntityHeadlinesStatus.initial && - state.headlinesStatus != EntityHeadlinesStatus.loadingMore && + state.status != EntityDetailsStatus.initial && + state.status != EntityDetailsStatus.loadingMore && state.status == EntityDetailsStatus.success) SliverFillRemaining( hasScrollBody: false, @@ -319,8 +313,7 @@ class _EntityDetailsViewState extends State { itemCount: state.feedItems.length + (state.hasMoreHeadlines && - state.headlinesStatus == - EntityHeadlinesStatus.loadingMore + state.status == EntityDetailsStatus.loadingMore ? 1 : 0), separatorBuilder: (context, index) => @@ -355,12 +348,6 @@ class _EntityDetailsViewState extends State { pathParameters: {'id': item.id}, extra: item, ), - currentContextEntityType: state.entityType, - currentContextEntityId: state.entity is Category - ? (state.entity as Category).id - : state.entity is Source - ? (state.entity as Source).id - : null, ); case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( @@ -370,12 +357,6 @@ class _EntityDetailsViewState extends State { pathParameters: {'id': item.id}, extra: item, ), - currentContextEntityType: state.entityType, - currentContextEntityId: state.entity is Category - ? (state.entity as Category).id - : state.entity is Source - ? (state.entity as Source).id - : null, ); case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( @@ -385,12 +366,6 @@ class _EntityDetailsViewState extends State { pathParameters: {'id': item.id}, extra: item, ), - currentContextEntityType: state.entityType, - currentContextEntityId: state.entity is Category - ? (state.entity as Category).id - : state.entity is Source - ? (state.entity as Source).id - : null, ); } return tile; @@ -399,13 +374,14 @@ class _EntityDetailsViewState extends State { }, ), ), - if (state.headlinesStatus == EntityHeadlinesStatus.failure && + if (state.status == EntityDetailsStatus.partialFailure && state.feedItems.isNotEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(AppSpacing.paddingMedium), child: Text( - state.errorMessage ?? l10n.failedToLoadMoreHeadlines, + state.exception?.toFriendlyMessage(context) ?? + l10n.failedToLoadMoreHeadlines, style: textTheme.bodyMedium?.copyWith( color: colorScheme.error, ), diff --git a/lib/headline-details/bloc/headline_details_bloc.dart b/lib/headline-details/bloc/headline_details_bloc.dart index cba1da29..45c99252 100644 --- a/lib/headline-details/bloc/headline_details_bloc.dart +++ b/lib/headline-details/bloc/headline_details_bloc.dart @@ -4,7 +4,7 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart' - show Headline, HtHttpException, NotFoundException; + show Headline, HtHttpException, UnknownException; part 'headline_details_event.dart'; part 'headline_details_state.dart'; @@ -28,12 +28,14 @@ class HeadlineDetailsBloc try { final headline = await _headlinesRepository.read(id: event.headlineId); emit(HeadlineDetailsLoaded(headline: headline)); - } on NotFoundException catch (e) { - emit(HeadlineDetailsFailure(message: e.message)); } on HtHttpException catch (e) { - emit(HeadlineDetailsFailure(message: e.message)); + emit(HeadlineDetailsFailure(exception: e)); } catch (e) { - emit(HeadlineDetailsFailure(message: 'An unexpected error occurred: $e')); + emit( + HeadlineDetailsFailure( + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); } } diff --git a/lib/headline-details/bloc/headline_details_state.dart b/lib/headline-details/bloc/headline_details_state.dart index df3fa3e2..42ae4f08 100644 --- a/lib/headline-details/bloc/headline_details_state.dart +++ b/lib/headline-details/bloc/headline_details_state.dart @@ -21,10 +21,10 @@ class HeadlineDetailsLoaded extends HeadlineDetailsState { } class HeadlineDetailsFailure extends HeadlineDetailsState { - const HeadlineDetailsFailure({required this.message}); + const HeadlineDetailsFailure({required this.exception}); - final String message; + final HtHttpException exception; @override - List get props => [message]; + List get props => [exception]; } diff --git a/lib/headline-details/bloc/similar_headlines_bloc.dart b/lib/headline-details/bloc/similar_headlines_bloc.dart index 3dda6351..2325b59c 100644 --- a/lib/headline-details/bloc/similar_headlines_bloc.dart +++ b/lib/headline-details/bloc/similar_headlines_bloc.dart @@ -3,7 +3,8 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; -import 'package:ht_shared/ht_shared.dart' show Headline, HtHttpException; +import 'package:ht_shared/ht_shared.dart' + show Headline, HtHttpException, PaginationOptions; part 'similar_headlines_event.dart'; part 'similar_headlines_state.dart'; @@ -27,19 +28,16 @@ class SimilarHeadlinesBloc emit(SimilarHeadlinesLoading()); try { final currentHeadline = event.currentHeadline; - if (currentHeadline.category == null || - currentHeadline.category!.id.isEmpty) { - emit(SimilarHeadlinesEmpty()); - return; - } - final queryParams = {'categories': currentHeadline.category!.id}; + final filter = {'topic.id': currentHeadline.topic.id}; - final response = await _headlinesRepository.readAllByQuery( - queryParams, - limit: - _similarHeadlinesLimit + - 1, // Fetch one extra to check if current is there + final response = await _headlinesRepository.readAll( + filter: filter, + pagination: const PaginationOptions( + limit: + _similarHeadlinesLimit + + 1, // Fetch one extra to check if current is there + ), ); // Filter out the current headline from the results diff --git a/lib/headline-details/view/headline_details_page.dart b/lib/headline-details/view/headline_details_page.dart index 45a90f6a..5d298c0c 100644 --- a/lib/headline-details/view/headline_details_page.dart +++ b/lib/headline-details/view/headline_details_page.dart @@ -14,6 +14,7 @@ import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; import 'package:ht_main/shared/shared.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; import 'package:intl/intl.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -49,7 +50,7 @@ class _HeadlineDetailsPageState extends State { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; return BlocListener( listener: (context, headlineState) { @@ -98,15 +99,12 @@ class _HeadlineDetailsPageState extends State { ) ?? false; if (accountState.status == AccountStatus.failure && - accountState.errorMessage != null) { + accountState.error != null) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( - content: Text( - accountState.errorMessage ?? - l10n.headlineSaveErrorSnackbar, - ), + content: Text(l10n.headlineSaveErrorSnackbar), backgroundColor: Theme.of(context).colorScheme.error, ), ); @@ -137,7 +135,7 @@ class _HeadlineDetailsPageState extends State { ), final HeadlineDetailsFailure failureState => FailureStateWidget( - message: failureState.message, + exception: failureState.exception, onRetry: () { if (widget.headlineId != null) { context.read().add( @@ -164,7 +162,7 @@ class _HeadlineDetailsPageState extends State { } Widget _buildLoadedContent(BuildContext context, Headline headline) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; @@ -207,15 +205,15 @@ class _HeadlineDetailsPageState extends State { sharePositionOrigin = box.localToGlobal(Offset.zero) & box.size; } ShareParams params; - if (kIsWeb && headline.url != null && headline.url!.isNotEmpty) { + if (kIsWeb && headline.url.isNotEmpty) { params = ShareParams( - uri: Uri.parse(headline.url!), + uri: Uri.parse(headline.url), title: headline.title, sharePositionOrigin: sharePositionOrigin, ); - } else if (headline.url != null && headline.url!.isNotEmpty) { + } else if (headline.url.isNotEmpty) { params = ShareParams( - text: '${headline.title}\n\n${headline.url!}', + text: '${headline.title}\n\n${headline.url}', subject: headline.title, sharePositionOrigin: sharePositionOrigin, ); @@ -271,67 +269,42 @@ class _HeadlineDetailsPageState extends State { ), ), ), - if (headline.imageUrl != null) - SliverPadding( - padding: EdgeInsets.only( - top: AppSpacing.md, - left: horizontalPadding.left, - right: horizontalPadding.right, - ), - sliver: SliverToBoxAdapter( - child: ClipRRect( - borderRadius: BorderRadius.circular(AppSpacing.md), - child: AspectRatio( - aspectRatio: 16 / 9, - child: Image.network( - headline.imageUrl!, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return ColoredBox( - color: colorScheme.surfaceContainerHighest, - child: const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - }, - errorBuilder: (context, error, stackTrace) => ColoredBox( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.broken_image_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xxl * 1.5, - ), - ), - ), - ), - ), - ), - ) - else // Placeholder if no image - SliverPadding( - padding: EdgeInsets.only( - top: AppSpacing.md, - left: horizontalPadding.left, - right: horizontalPadding.right, - ), - sliver: SliverToBoxAdapter( + SliverPadding( + padding: EdgeInsets.only( + top: AppSpacing.md, + left: horizontalPadding.left, + right: horizontalPadding.right, + ), + sliver: SliverToBoxAdapter( + child: ClipRRect( + borderRadius: BorderRadius.circular(AppSpacing.md), child: AspectRatio( aspectRatio: 16 / 9, - child: Container( - decoration: BoxDecoration( + child: Image.network( + headline.imageUrl, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return ColoredBox( + color: colorScheme.surfaceContainerHighest, + child: const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + }, + errorBuilder: (context, error, stackTrace) => ColoredBox( color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(AppSpacing.md), - ), - child: Icon( - Icons.image_not_supported_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xxl * 1.5, + child: Icon( + Icons.broken_image_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.xxl * 1.5, + ), ), ), ), ), ), + ), SliverPadding( padding: horizontalPadding.copyWith(top: AppSpacing.lg), sliver: SliverToBoxAdapter( @@ -342,12 +315,12 @@ class _HeadlineDetailsPageState extends State { ), ), ), - if (headline.description != null && headline.description!.isNotEmpty) + if (headline.excerpt.isNotEmpty) SliverPadding( padding: horizontalPadding.copyWith(top: AppSpacing.lg), sliver: SliverToBoxAdapter( child: Text( - headline.description!, + headline.excerpt, style: textTheme.bodyLarge?.copyWith( color: colorScheme.onSurfaceVariant, height: 1.6, @@ -355,7 +328,7 @@ class _HeadlineDetailsPageState extends State { ), ), ), - if (headline.url != null && headline.url!.isNotEmpty) + if (headline.url.isNotEmpty) SliverPadding( padding: horizontalPadding.copyWith( top: AppSpacing.xl, @@ -365,7 +338,7 @@ class _HeadlineDetailsPageState extends State { child: ElevatedButton.icon( icon: const Icon(Icons.open_in_new_outlined), onPressed: () async { - await launchUrlString(headline.url!); + await launchUrlString(headline.url); }, label: Text(l10n.headlineDetailsContinueReadingButton), style: ElevatedButton.styleFrom( @@ -380,8 +353,7 @@ class _HeadlineDetailsPageState extends State { ), ), ), - if (headline.url == null || - headline.url!.isEmpty) // Ensure bottom padding + if (headline.url.isEmpty) // Ensure bottom padding const SliverPadding( padding: EdgeInsets.only(bottom: AppSpacing.xl), sliver: SliverToBoxAdapter(child: SizedBox.shrink()), @@ -391,9 +363,7 @@ class _HeadlineDetailsPageState extends State { sliver: SliverToBoxAdapter( child: Padding( padding: EdgeInsets.only( - top: (headline.url != null && headline.url!.isNotEmpty) - ? AppSpacing.sm - : AppSpacing.xl, + top: (headline.url.isNotEmpty) ? AppSpacing.sm : AppSpacing.xl, bottom: AppSpacing.md, ), child: Text( @@ -431,18 +401,40 @@ class _HeadlineDetailsPageState extends State { final chips = []; - if (headline.publishedAt != null) { - final formattedDate = DateFormat( - 'MMM d, yyyy', - ).format(headline.publishedAt!); - chips.add( - Chip( + final formattedDate = DateFormat('MMM d, yyyy').format(headline.createdAt); + chips.add( + Chip( + avatar: Icon( + Icons.calendar_today_outlined, + size: chipAvatarSize, + color: chipAvatarColor, + ), + label: Text(formattedDate), + labelStyle: chipLabelStyle, + backgroundColor: chipBackgroundColor, + padding: chipPadding, + shape: chipShape, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + + chips.add( + InkWell( + onTap: () { + context.push( + Routes.sourceDetails, + extra: EntityDetailsPageArguments(entity: headline.source), + ); + }, + borderRadius: BorderRadius.circular(AppSpacing.sm), + child: Chip( avatar: Icon( - Icons.calendar_today_outlined, + Icons.source_outlined, size: chipAvatarSize, color: chipAvatarColor, ), - label: Text(formattedDate), + label: Text(headline.source.name), labelStyle: chipLabelStyle, backgroundColor: chipBackgroundColor, padding: chipPadding, @@ -450,66 +442,35 @@ class _HeadlineDetailsPageState extends State { visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - ); - } + ), + ); - if (headline.source != null) { - chips.add( - InkWell( - // Make chip tappable - onTap: () { - context.push( - Routes.sourceDetails, - extra: EntityDetailsPageArguments(entity: headline.source), - ); - }, - borderRadius: BorderRadius.circular(AppSpacing.sm), - child: Chip( - avatar: Icon( - Icons.source_outlined, - size: chipAvatarSize, - color: chipAvatarColor, - ), - label: Text(headline.source!.name), - labelStyle: chipLabelStyle, - backgroundColor: chipBackgroundColor, - padding: chipPadding, - shape: chipShape, - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + chips.add( + InkWell( + onTap: () { + context.push( + Routes.topicDetails, + extra: EntityDetailsPageArguments(entity: headline.topic), + ); + }, + borderRadius: BorderRadius.circular(AppSpacing.sm), + child: Chip( + avatar: Icon( + Icons.category_outlined, + size: chipAvatarSize, + color: chipAvatarColor, ), + label: Text(headline.topic.name), + labelStyle: chipLabelStyle, + backgroundColor: chipBackgroundColor, + padding: chipPadding, + shape: chipShape, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - ); - } + ), + ); - if (headline.category != null) { - chips.add( - InkWell( - // Make chip tappable - onTap: () { - context.push( - Routes.categoryDetails, - extra: EntityDetailsPageArguments(entity: headline.category), - ); - }, - borderRadius: BorderRadius.circular(AppSpacing.sm), - child: Chip( - avatar: Icon( - Icons.category_outlined, - size: chipAvatarSize, - color: chipAvatarColor, - ), - label: Text(headline.category!.name), - labelStyle: chipLabelStyle, - backgroundColor: chipBackgroundColor, - padding: chipPadding, - shape: chipShape, - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - ); - } return chips; } @@ -517,7 +478,7 @@ class _HeadlineDetailsPageState extends State { BuildContext context, EdgeInsets hPadding, ) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; diff --git a/lib/headlines-feed/bloc/categories_filter_bloc.dart b/lib/headlines-feed/bloc/categories_filter_bloc.dart deleted file mode 100644 index 63e9887b..00000000 --- a/lib/headlines-feed/bloc/categories_filter_bloc.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'dart:async'; - -import 'package:bloc/bloc.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:equatable/equatable.dart'; -import 'package:ht_data_repository/ht_data_repository.dart'; -import 'package:ht_shared/ht_shared.dart' show Category, HtHttpException; - -part 'categories_filter_event.dart'; -part 'categories_filter_state.dart'; - -/// {@template categories_filter_bloc} -/// Manages the state for fetching and displaying categories for filtering. -/// -/// Handles initial fetching and pagination of categories using the -/// provided [HtDataRepository]. -/// {@endtemplate} -class CategoriesFilterBloc - extends Bloc { - /// {@macro categories_filter_bloc} - /// - /// Requires a [HtDataRepository] to interact with the data layer. - CategoriesFilterBloc({ - required HtDataRepository categoriesRepository, - }) : _categoriesRepository = categoriesRepository, - super(const CategoriesFilterState()) { - on( - _onCategoriesFilterRequested, - transformer: restartable(), - ); - on( - _onCategoriesFilterLoadMoreRequested, - transformer: droppable(), - ); - } - - final HtDataRepository _categoriesRepository; - - /// Number of categories to fetch per page. - static const _categoriesLimit = 20; - - /// Handles the initial request to fetch categories. - Future _onCategoriesFilterRequested( - CategoriesFilterRequested event, - Emitter emit, - ) async { - // Prevent fetching if already loading or successful (unless forced refresh) - if (state.status == CategoriesFilterStatus.loading || - state.status == CategoriesFilterStatus.success) { - // Optionally add logic here for forced refresh if needed - return; - } - - emit(state.copyWith(status: CategoriesFilterStatus.loading)); - - try { - final response = await _categoriesRepository.readAll( - limit: _categoriesLimit, - ); - emit( - state.copyWith( - status: CategoriesFilterStatus.success, - categories: response.items, - hasMore: response.hasMore, - cursor: response.cursor, - clearError: true, - ), - ); - } on HtHttpException catch (e) { - emit(state.copyWith(status: CategoriesFilterStatus.failure, error: e)); - } catch (e) { - // Catch unexpected errors - emit(state.copyWith(status: CategoriesFilterStatus.failure, error: e)); - } - } - - /// Handles the request to load more categories for pagination. - Future _onCategoriesFilterLoadMoreRequested( - CategoriesFilterLoadMoreRequested event, - Emitter emit, - ) async { - // Only proceed if currently successful and has more items - if (state.status != CategoriesFilterStatus.success || !state.hasMore) { - return; - } - - emit(state.copyWith(status: CategoriesFilterStatus.loadingMore)); - - try { - final response = await _categoriesRepository.readAll( - limit: _categoriesLimit, - startAfterId: state.cursor, - ); - emit( - state.copyWith( - status: CategoriesFilterStatus.success, - // Append new categories to the existing list - categories: List.of(state.categories)..addAll(response.items), - hasMore: response.hasMore, - cursor: response.cursor, - ), - ); - } on HtHttpException catch (e) { - // Keep existing data but indicate failure - emit(state.copyWith(status: CategoriesFilterStatus.failure, error: e)); - } catch (e) { - // Catch unexpected errors - emit(state.copyWith(status: CategoriesFilterStatus.failure, error: e)); - } - } -} diff --git a/lib/headlines-feed/bloc/categories_filter_event.dart b/lib/headlines-feed/bloc/categories_filter_event.dart deleted file mode 100644 index f80e2957..00000000 --- a/lib/headlines-feed/bloc/categories_filter_event.dart +++ /dev/null @@ -1,22 +0,0 @@ -part of 'categories_filter_bloc.dart'; - -/// {@template categories_filter_event} -/// Base class for events related to fetching and managing category filters. -/// {@endtemplate} -sealed class CategoriesFilterEvent extends Equatable { - /// {@macro categories_filter_event} - const CategoriesFilterEvent(); - - @override - List get props => []; -} - -/// {@template categories_filter_requested} -/// Event triggered to request the initial list of categories. -/// {@endtemplate} -final class CategoriesFilterRequested extends CategoriesFilterEvent {} - -/// {@template categories_filter_load_more_requested} -/// Event triggered to request the next page of categories for pagination. -/// {@endtemplate} -final class CategoriesFilterLoadMoreRequested extends CategoriesFilterEvent {} diff --git a/lib/headlines-feed/bloc/categories_filter_state.dart b/lib/headlines-feed/bloc/categories_filter_state.dart deleted file mode 100644 index e0158da7..00000000 --- a/lib/headlines-feed/bloc/categories_filter_state.dart +++ /dev/null @@ -1,76 +0,0 @@ -part of 'categories_filter_bloc.dart'; - -/// Enum representing the different statuses of the category filter data fetching. -enum CategoriesFilterStatus { - /// Initial state, no data loaded yet. - initial, - - /// Currently fetching the first page of categories. - loading, - - /// Successfully loaded categories. May be loading more in the background. - success, - - /// An error occurred while fetching categories. - failure, - - /// Loading more categories for pagination (infinity scroll). - loadingMore, -} - -/// {@template categories_filter_state} -/// Represents the state for the category filter feature. -/// -/// Contains the list of fetched categories, pagination information, -/// loading/error status. -/// {@endtemplate} -final class CategoriesFilterState extends Equatable { - /// {@macro categories_filter_state} - const CategoriesFilterState({ - this.status = CategoriesFilterStatus.initial, - this.categories = const [], - this.hasMore = true, - this.cursor, - this.error, - }); - - /// The current status of fetching categories. - final CategoriesFilterStatus status; - - /// The list of [Category] objects fetched so far. - final List categories; - - /// Flag indicating if there are more categories available to fetch. - final bool hasMore; - - /// The cursor string to fetch the next page of categories. - /// This is typically the ID of the last fetched category. - final String? cursor; - - /// An optional error object if the status is [CategoriesFilterStatus.failure]. - final Object? error; - - /// Creates a copy of this state with the given fields replaced. - CategoriesFilterState copyWith({ - CategoriesFilterStatus? status, - List? categories, - bool? hasMore, - String? cursor, - Object? error, - bool clearError = false, - bool clearCursor = false, - }) { - return CategoriesFilterState( - status: status ?? this.status, - categories: categories ?? this.categories, - hasMore: hasMore ?? this.hasMore, - // Allow explicitly setting cursor to null or clearing it - cursor: clearCursor ? null : (cursor ?? this.cursor), - // Clear error if requested, otherwise keep existing or use new one - error: clearError ? null : error ?? this.error, - ); - } - - @override - List get props => [status, categories, hasMore, cursor, error]; -} diff --git a/lib/headlines-feed/bloc/countries_filter_bloc.dart b/lib/headlines-feed/bloc/countries_filter_bloc.dart index a3978338..86418e05 100644 --- a/lib/headlines-feed/bloc/countries_filter_bloc.dart +++ b/lib/headlines-feed/bloc/countries_filter_bloc.dart @@ -4,7 +4,7 @@ import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:equatable/equatable.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; -import 'package:ht_shared/ht_shared.dart' show Country, HtHttpException; +import 'package:ht_shared/ht_shared.dart'; part 'countries_filter_event.dart'; part 'countries_filter_state.dart'; @@ -43,7 +43,6 @@ class CountriesFilterBloc CountriesFilterRequested event, Emitter emit, ) async { - // Prevent fetching if already loading or successful if (state.status == CountriesFilterStatus.loading || state.status == CountriesFilterStatus.success) { return; @@ -53,7 +52,7 @@ class CountriesFilterBloc try { final response = await _countriesRepository.readAll( - limit: _countriesLimit, + pagination: const PaginationOptions(limit: _countriesLimit), ); emit( state.copyWith( @@ -66,9 +65,6 @@ class CountriesFilterBloc ); } on HtHttpException catch (e) { emit(state.copyWith(status: CountriesFilterStatus.failure, error: e)); - } catch (e) { - // Catch unexpected errors - emit(state.copyWith(status: CountriesFilterStatus.failure, error: e)); } } @@ -77,7 +73,6 @@ class CountriesFilterBloc CountriesFilterLoadMoreRequested event, Emitter emit, ) async { - // Only proceed if currently successful and has more items if (state.status != CountriesFilterStatus.success || !state.hasMore) { return; } @@ -86,23 +81,20 @@ class CountriesFilterBloc try { final response = await _countriesRepository.readAll( - limit: _countriesLimit, - startAfterId: state.cursor, + pagination: PaginationOptions( + limit: _countriesLimit, + cursor: state.cursor, + ), ); emit( state.copyWith( status: CountriesFilterStatus.success, - // Append new countries to the existing list countries: List.of(state.countries)..addAll(response.items), hasMore: response.hasMore, cursor: response.cursor, ), ); } on HtHttpException catch (e) { - // Keep existing data but indicate failure - emit(state.copyWith(status: CountriesFilterStatus.failure, error: e)); - } catch (e) { - // Catch unexpected errors emit(state.copyWith(status: CountriesFilterStatus.failure, error: e)); } } diff --git a/lib/headlines-feed/bloc/countries_filter_state.dart b/lib/headlines-feed/bloc/countries_filter_state.dart index 2be4546d..a274db17 100644 --- a/lib/headlines-feed/bloc/countries_filter_state.dart +++ b/lib/headlines-feed/bloc/countries_filter_state.dart @@ -50,7 +50,7 @@ final class CountriesFilterState extends Equatable { final String? cursor; /// An optional error object if the status is [CountriesFilterStatus.failure]. - final Object? error; + final HtHttpException? error; /// Creates a copy of this state with the given fields replaced. CountriesFilterState copyWith({ @@ -58,7 +58,7 @@ final class CountriesFilterState extends Equatable { List? countries, bool? hasMore, String? cursor, - Object? error, + HtHttpException? error, bool clearError = false, bool clearCursor = false, }) { diff --git a/lib/headlines-feed/bloc/headlines_feed_bloc.dart b/lib/headlines-feed/bloc/headlines_feed_bloc.dart index e0a6bbb9..fa57264d 100644 --- a/lib/headlines-feed/bloc/headlines_feed_bloc.dart +++ b/lib/headlines-feed/bloc/headlines_feed_bloc.dart @@ -30,341 +30,270 @@ class HeadlinesFeedBloc extends Bloc { }) : _headlinesRepository = headlinesRepository, _feedInjectorService = feedInjectorService, _appBloc = appBloc, - super(HeadlinesFeedInitial()) { + super(const HeadlinesFeedState()) { on( _onHeadlinesFeedFetchRequested, - transformer: - sequential(), // Ensures fetch requests are processed one by one + transformer: droppable(), ); on( _onHeadlinesFeedRefreshRequested, - transformer: - restartable(), // Ensures only the latest refresh is processed + transformer: restartable(), + ); + on( + _onHeadlinesFeedFiltersApplied, + transformer: restartable(), + ); + on( + _onHeadlinesFeedFiltersCleared, + transformer: restartable(), ); - on(_onHeadlinesFeedFiltersApplied); - on(_onHeadlinesFeedFiltersCleared); } final HtDataRepository _headlinesRepository; final FeedInjectorService _feedInjectorService; final AppBloc _appBloc; - /// The number of headlines to fetch per page during pagination or initial load. + /// The number of headlines to fetch per page. static const _headlinesFetchLimit = 10; - /// Handles the [HeadlinesFeedFiltersApplied] event. - /// - /// Emits [HeadlinesFeedLoading] state, then fetches the first page of - /// headlines using the filters provided in the event. Updates the state - /// with the new headlines and the applied filter. Emits [HeadlinesFeedError] - /// if fetching fails. - Future _onHeadlinesFeedFiltersApplied( - HeadlinesFeedFiltersApplied event, + Map _buildFilter(HeadlineFilter filter) { + final queryFilter = {}; + if (filter.topics?.isNotEmpty ?? false) { + queryFilter['topic.id'] = { + r'$in': filter.topics!.map((t) => t.id).toList(), + }; + } + if (filter.sources?.isNotEmpty ?? false) { + queryFilter['source.id'] = { + r'$in': filter.sources!.map((s) => s.id).toList(), + }; + } + return queryFilter; + } + + Future _onHeadlinesFeedFetchRequested( + HeadlinesFeedFetchRequested event, Emitter emit, ) async { - emit(HeadlinesFeedLoading()); - try { - final queryParams = {}; - if (event.filter.categories?.isNotEmpty ?? false) { - queryParams['categories'] = event.filter.categories! - .map((c) => c.id) - .join(','); - } - if (event.filter.sources?.isNotEmpty ?? false) { - queryParams['sources'] = event.filter.sources! - .map((s) => s.id) - .join(','); - } + if (state.status == HeadlinesFeedStatus.loading || !state.hasMore) return; - final headlineResponse = await _headlinesRepository.readAllByQuery( - queryParams, - limit: _headlinesFetchLimit, - ); + emit(state.copyWith(status: HeadlinesFeedStatus.loadingMore)); + try { final currentUser = _appBloc.state.user; - final appConfig = _appBloc.state.appConfig; + final remoteConfig = _appBloc.state.remoteConfig; - if (appConfig == null) { - // AppConfig is crucial for injection rules. - emit( - const HeadlinesFeedError(message: 'App configuration not available.'), - ); + if (remoteConfig == null) { + emit(state.copyWith(status: HeadlinesFeedStatus.failure)); return; } - final processedFeedItems = _feedInjectorService.injectItems( + final headlineResponse = await _headlinesRepository.readAll( + filter: _buildFilter(state.filter), + pagination: PaginationOptions( + limit: _headlinesFetchLimit, + cursor: state.cursor, + ), + ); + + final newProcessedFeedItems = _feedInjectorService.injectItems( headlines: headlineResponse.items, user: currentUser, - appConfig: appConfig, - currentFeedItemCount: 0, + remoteConfig: remoteConfig, + currentFeedItemCount: state.feedItems.length, ); emit( - HeadlinesFeedLoaded( - feedItems: processedFeedItems, - hasMore: headlineResponse.hasMore, + state.copyWith( + status: HeadlinesFeedStatus.success, + feedItems: List.of(state.feedItems)..addAll(newProcessedFeedItems), + hasMore: headlineResponse.cursor != null, cursor: headlineResponse.cursor, - filter: event.filter, ), ); - // Dispatch event if AccountAction was injected - if (processedFeedItems.any((item) => item is AccountAction) && - _appBloc.state.user?.id != null) { + if (newProcessedFeedItems.any((item) => item is FeedAction) && + currentUser?.id != null) { _appBloc.add( - AppUserAccountActionShown(userId: _appBloc.state.user!.id), + AppUserAccountActionShown( + userId: currentUser!.id, + feedActionType: + (newProcessedFeedItems.firstWhere((item) => item is FeedAction) + as FeedAction) + .feedActionType, + isCompleted: false, + ), ); } } on HtHttpException catch (e) { - emit(HeadlinesFeedError(message: e.message)); - } catch (e, st) { - // Log the error and stack trace for unexpected errors - // Consider using a proper logging framework - print('Unexpected error in _onHeadlinesFeedFiltersApplied: $e\n$st'); - emit(const HeadlinesFeedError(message: 'An unexpected error occurred')); + emit(state.copyWith(status: HeadlinesFeedStatus.failure, error: e)); } } - /// Handles clearing all applied filters. - /// - /// Fetches the first page of headlines without any filters. - Future _onHeadlinesFeedFiltersCleared( - HeadlinesFeedFiltersCleared event, + Future _onHeadlinesFeedRefreshRequested( + HeadlinesFeedRefreshRequested event, Emitter emit, ) async { - emit(HeadlinesFeedLoading()); + emit(state.copyWith(status: HeadlinesFeedStatus.loading)); try { - // Fetch the first page with no filters - final headlineResponse = await _headlinesRepository.readAll( - limit: _headlinesFetchLimit, - ); - final currentUser = _appBloc.state.user; - final appConfig = _appBloc.state.appConfig; + final appConfig = _appBloc.state.remoteConfig; if (appConfig == null) { - emit( - const HeadlinesFeedError(message: 'App configuration not available.'), - ); + emit(state.copyWith(status: HeadlinesFeedStatus.failure)); return; } + final headlineResponse = await _headlinesRepository.readAll( + filter: _buildFilter(state.filter), + pagination: const PaginationOptions(limit: _headlinesFetchLimit), + ); + final processedFeedItems = _feedInjectorService.injectItems( headlines: headlineResponse.items, user: currentUser, - appConfig: appConfig, + remoteConfig: appConfig, currentFeedItemCount: 0, ); emit( - HeadlinesFeedLoaded( + state.copyWith( + status: HeadlinesFeedStatus.success, feedItems: processedFeedItems, - hasMore: headlineResponse.hasMore, + hasMore: headlineResponse.cursor != null, cursor: headlineResponse.cursor, - filter: const HeadlineFilter(), + filter: state.filter, ), ); - // Dispatch event if AccountAction was injected - if (processedFeedItems.any((item) => item is AccountAction) && - _appBloc.state.user?.id != null) { + if (processedFeedItems.any((item) => item is FeedAction) && + currentUser?.id != null) { _appBloc.add( - AppUserAccountActionShown(userId: _appBloc.state.user!.id), + AppUserAccountActionShown( + userId: currentUser!.id, + feedActionType: + (processedFeedItems.firstWhere((item) => item is FeedAction) + as FeedAction) + .feedActionType, + isCompleted: false, + ), ); } } on HtHttpException catch (e) { - emit(HeadlinesFeedError(message: e.message)); - } catch (e, st) { - // Log the error and stack trace for unexpected errors - print('Unexpected error in _onHeadlinesFeedFiltersCleared: $e\n$st'); - emit(const HeadlinesFeedError(message: 'An unexpected error occurred')); + emit(state.copyWith(status: HeadlinesFeedStatus.failure, error: e)); } } - /// Handles the [HeadlinesFeedFetchRequested] event for initial load and pagination. - /// - /// Determines if it's an initial load or pagination based on the current state - /// and the presence of a cursor in the event. Fetches headlines using the - /// currently active filter stored in the state. Emits appropriate loading - /// states ([HeadlinesFeedLoading] or [HeadlinesFeedLoadingSilently]) and - /// updates the state with fetched headlines or an error. - Future _onHeadlinesFeedFetchRequested( - HeadlinesFeedFetchRequested event, + Future _onHeadlinesFeedFiltersApplied( + HeadlinesFeedFiltersApplied event, Emitter emit, ) async { - // Determine current filter and cursor based on state - var currentFilter = const HeadlineFilter(); - var currentCursorForFetch = event.cursor; - var currentFeedItems = []; - var isPaginating = false; - var currentFeedItemCountForInjector = 0; - - if (state is HeadlinesFeedLoaded) { - final loadedState = state as HeadlinesFeedLoaded; - currentFilter = loadedState.filter; - currentFeedItems = loadedState.feedItems; - currentFeedItemCountForInjector = loadedState.feedItems.length; - - if (event.cursor != null) { - // Explicit pagination request - if (!loadedState.hasMore) return; - isPaginating = true; - currentCursorForFetch = loadedState.cursor; - } else { - // Initial fetch or refresh (event.cursor is null) - currentFeedItems = []; - currentFeedItemCountForInjector = 0; - } - } else if (state is HeadlinesFeedLoading || - state is HeadlinesFeedLoadingSilently) { - if (event.cursor == null) return; - } - // For initial load or if event.cursor is null, currentCursorForFetch remains null. - emit( - isPaginating ? HeadlinesFeedLoadingSilently() : HeadlinesFeedLoading(), + state.copyWith( + status: HeadlinesFeedStatus.loading, + filter: event.filter, + feedItems: [], + cursor: null, + clearCursor: true, + ), ); - try { final currentUser = _appBloc.state.user; - final appConfig = _appBloc.state.appConfig; + final appConfig = _appBloc.state.remoteConfig; if (appConfig == null) { - emit( - const HeadlinesFeedError(message: 'App configuration not available.'), - ); + emit(state.copyWith(status: HeadlinesFeedStatus.failure)); return; } - final queryParams = {}; - if (currentFilter.categories?.isNotEmpty ?? false) { - queryParams['categories'] = currentFilter.categories! - .map((c) => c.id) - .join(','); - } - if (currentFilter.sources?.isNotEmpty ?? false) { - queryParams['sources'] = currentFilter.sources! - .map((s) => s.id) - .join(','); - } - - final headlineResponse = await _headlinesRepository.readAllByQuery( - queryParams, - limit: _headlinesFetchLimit, - startAfterId: currentCursorForFetch, + final headlineResponse = await _headlinesRepository.readAll( + filter: _buildFilter(event.filter), + pagination: const PaginationOptions(limit: _headlinesFetchLimit), ); - final newProcessedFeedItems = _feedInjectorService.injectItems( + final processedFeedItems = _feedInjectorService.injectItems( headlines: headlineResponse.items, user: currentUser, - appConfig: appConfig, - currentFeedItemCount: currentFeedItemCountForInjector, + remoteConfig: appConfig, + currentFeedItemCount: 0, ); - final resultingFeedItems = isPaginating - ? (List.of(currentFeedItems)..addAll(newProcessedFeedItems)) - : newProcessedFeedItems; - emit( - HeadlinesFeedLoaded( - feedItems: resultingFeedItems, - hasMore: headlineResponse.hasMore, + state.copyWith( + status: HeadlinesFeedStatus.success, + feedItems: processedFeedItems, + hasMore: headlineResponse.cursor != null, cursor: headlineResponse.cursor, - filter: currentFilter, ), ); - // Dispatch event if AccountAction was injected - if (newProcessedFeedItems.any((item) => item is AccountAction) && - _appBloc.state.user?.id != null) { + if (processedFeedItems.any((item) => item is FeedAction) && + currentUser?.id != null) { _appBloc.add( - AppUserAccountActionShown(userId: _appBloc.state.user!.id), + AppUserAccountActionShown( + userId: currentUser!.id, + feedActionType: + (processedFeedItems.firstWhere((item) => item is FeedAction) + as FeedAction) + .feedActionType, + isCompleted: false, + ), ); } } on HtHttpException catch (e) { - emit(HeadlinesFeedError(message: e.message)); - } catch (e, st) { - print('Unexpected error in _onHeadlinesFeedFetchRequested: $e\n$st'); - emit(const HeadlinesFeedError(message: 'An unexpected error occurred')); + emit(state.copyWith(status: HeadlinesFeedStatus.failure, error: e)); } } - /// Handles [HeadlinesFeedRefreshRequested] events for pull-to-refresh. - /// - /// Fetches the first page of headlines using the currently applied filter (if any). - /// Uses `restartable` transformer to ensure only the latest request is processed. - Future _onHeadlinesFeedRefreshRequested( - HeadlinesFeedRefreshRequested event, + Future _onHeadlinesFeedFiltersCleared( + HeadlinesFeedFiltersCleared event, Emitter emit, ) async { - emit(HeadlinesFeedLoading()); - - var currentFilter = const HeadlineFilter(); - if (state is HeadlinesFeedLoaded) { - currentFilter = (state as HeadlinesFeedLoaded).filter; - } - + emit( + state.copyWith( + status: HeadlinesFeedStatus.loading, + filter: const HeadlineFilter(), + feedItems: [], + cursor: null, + clearCursor: true, + ), + ); try { final currentUser = _appBloc.state.user; - final appConfig = _appBloc.state.appConfig; + final appConfig = _appBloc.state.remoteConfig; if (appConfig == null) { - emit( - const HeadlinesFeedError(message: 'App configuration not available.'), - ); + emit(state.copyWith(status: HeadlinesFeedStatus.failure)); return; } - final queryParams = {}; - if (currentFilter.categories?.isNotEmpty ?? false) { - queryParams['categories'] = currentFilter.categories! - .map((c) => c.id) - .join(','); - } - if (currentFilter.sources?.isNotEmpty ?? false) { - queryParams['sources'] = currentFilter.sources! - .map((s) => s.id) - .join(','); - } - - final headlineResponse = await _headlinesRepository.readAllByQuery( - queryParams, - limit: _headlinesFetchLimit, + final headlineResponse = await _headlinesRepository.readAll( + pagination: const PaginationOptions(limit: _headlinesFetchLimit), ); - final headlinesToInject = headlineResponse.items; - final userForInjector = currentUser; - final configForInjector = appConfig; - const itemCountForInjector = 0; - final processedFeedItems = _feedInjectorService.injectItems( - headlines: headlinesToInject, - user: userForInjector, - appConfig: configForInjector, - currentFeedItemCount: itemCountForInjector, + headlines: headlineResponse.items, + user: currentUser, + remoteConfig: appConfig, + currentFeedItemCount: 0, ); emit( - HeadlinesFeedLoaded( + state.copyWith( + status: HeadlinesFeedStatus.success, feedItems: processedFeedItems, - hasMore: headlineResponse.hasMore, + hasMore: headlineResponse.cursor != null, cursor: headlineResponse.cursor, - filter: currentFilter, ), ); - // Dispatch event if AccountAction was injected - if (processedFeedItems.any((item) => item is AccountAction) && - _appBloc.state.user?.id != null) { - _appBloc.add( - AppUserAccountActionShown(userId: _appBloc.state.user!.id), - ); + if (processedFeedItems.any((item) => item is FeedAction) && + currentUser?.id != null) { + // TODO(ht-development): Implement correct event dispatching + // _appBloc.add(AppUserFeedActionShown(userId: currentUser!.id)); } } on HtHttpException catch (e) { - emit(HeadlinesFeedError(message: e.message)); - } catch (e, st) { - print('Unexpected error in _onHeadlinesFeedRefreshRequested: $e\n$st'); - emit(const HeadlinesFeedError(message: 'An unexpected error occurred')); + emit(state.copyWith(status: HeadlinesFeedStatus.failure, error: e)); } } } diff --git a/lib/headlines-feed/bloc/headlines_feed_state.dart b/lib/headlines-feed/bloc/headlines_feed_state.dart index a9a9df22..1556f78d 100644 --- a/lib/headlines-feed/bloc/headlines_feed_state.dart +++ b/lib/headlines-feed/bloc/headlines_feed_state.dart @@ -1,98 +1,50 @@ part of 'headlines_feed_bloc.dart'; -// Removed import from here, will be added to headlines_feed_bloc.dart +enum HeadlinesFeedStatus { initial, loading, success, failure, loadingMore } -/// {@template headlines_feed_state} -/// Represents the possible states of the headlines feed feature. -/// {@endtemplate} -sealed class HeadlinesFeedState extends Equatable { - /// {@macro headlines_feed_state} - const HeadlinesFeedState(); - - @override - List get props => []; -} - -/// {@template headlines_feed_initial} -/// The initial state of the headlines feed before any loading has begun. -/// {@endtemplate} -final class HeadlinesFeedInitial extends HeadlinesFeedState {} - -/// {@template headlines_feed_loading} -/// State indicating that the headlines feed is currently being fetched, -/// typically shown with a full-screen loading indicator. This is used for -/// initial loads, refreshes, or when applying/clearing filters. -/// {@endtemplate} -final class HeadlinesFeedLoading extends HeadlinesFeedState {} - -/// {@template headlines_feed_loading_silently} -/// State indicating that more headlines are being fetched for pagination -/// (infinity scrolling). This state usually doesn't trigger a full-screen -/// loading indicator, allowing the existing list to remain visible while -/// a smaller indicator might be shown at the bottom. -/// {@endtemplate} -final class HeadlinesFeedLoadingSilently extends HeadlinesFeedState {} - -/// {@template headlines_feed_loaded} -/// State indicating that a batch of headlines has been successfully loaded. -/// Contains the list of headlines, pagination information, and the currently -/// active filter configuration. -/// {@endtemplate} -final class HeadlinesFeedLoaded extends HeadlinesFeedState { - /// {@macro headlines_feed_loaded} - const HeadlinesFeedLoaded({ +class HeadlinesFeedState extends Equatable { + const HeadlinesFeedState({ + this.status = HeadlinesFeedStatus.initial, this.feedItems = const [], this.hasMore = true, this.cursor, this.filter = const HeadlineFilter(), + this.error, }); - /// The list of [FeedItem] objects currently loaded. + final HeadlinesFeedStatus status; final List feedItems; - - /// Flag indicating if there are more headlines available to fetch - /// via pagination. `true` if more might exist, `false` otherwise. final bool hasMore; - - /// The cursor string to be used to fetch the next page of headlines. - /// Null if there are no more pages or if pagination is not applicable. final String? cursor; - - /// The [HeadlineFilter] currently applied to the feed. An empty filter - /// indicates that no filters are active. final HeadlineFilter filter; + final HtHttpException? error; - /// Creates a copy of this [HeadlinesFeedLoaded] state with the given fields - /// replaced with new values. - HeadlinesFeedLoaded copyWith({ + HeadlinesFeedState copyWith({ + HeadlinesFeedStatus? status, List? feedItems, bool? hasMore, String? cursor, HeadlineFilter? filter, + HtHttpException? error, + bool clearCursor = false, }) { - return HeadlinesFeedLoaded( + return HeadlinesFeedState( + status: status ?? this.status, feedItems: feedItems ?? this.feedItems, hasMore: hasMore ?? this.hasMore, - cursor: cursor ?? this.cursor, + cursor: clearCursor ? null : cursor ?? this.cursor, filter: filter ?? this.filter, + error: error ?? this.error, ); } @override - List get props => [feedItems, hasMore, cursor, filter]; -} - -/// {@template headlines_feed_error} -/// State indicating that an error occurred while fetching headlines. -/// Contains an error [message] describing the failure. -/// {@endtemplate} -final class HeadlinesFeedError extends HeadlinesFeedState { - /// {@macro headlines_feed_error} - const HeadlinesFeedError({required this.message}); - - /// A message describing the error that occurred. - final String message; - - @override - List get props => [message]; + List get props => [ + status, + feedItems, + hasMore, + cursor, + filter, + error, + ]; } diff --git a/lib/headlines-feed/bloc/sources_filter_bloc.dart b/lib/headlines-feed/bloc/sources_filter_bloc.dart index 9b127923..97a4234b 100644 --- a/lib/headlines-feed/bloc/sources_filter_bloc.dart +++ b/lib/headlines-feed/bloc/sources_filter_bloc.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; -import 'package:ht_shared/ht_shared.dart' show Country, Source, SourceType; +import 'package:ht_shared/ht_shared.dart'; part 'sources_filter_event.dart'; part 'sources_filter_state.dart'; @@ -35,7 +35,7 @@ class SourcesFilterBloc extends Bloc { state.copyWith(dataLoadingStatus: SourceFilterDataLoadingStatus.loading), ); try { - final availableCountries = await _countriesRepository.readAll(); + final availableCountries = (await _countriesRepository.readAll()).items; final initialSelectedSourceIds = event.initialSelectedSources .map((s) => s.id) .toSet(); @@ -45,8 +45,7 @@ class SourcesFilterBloc extends Bloc { event.initialSelectedCountryIsoCodes; final initialSelectedSourceTypes = event.initialSelectedSourceTypes; - final allSourcesResponse = await _sourcesRepository.readAll(); - final allAvailableSources = allSourcesResponse.items; + 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. @@ -59,7 +58,7 @@ class SourcesFilterBloc extends Bloc { emit( state.copyWith( - availableCountries: availableCountries.items, + availableCountries: availableCountries, allAvailableSources: allAvailableSources, displayableSources: displayableSources, finallySelectedSourceIds: initialSelectedSourceIds, @@ -69,11 +68,11 @@ class SourcesFilterBloc extends Bloc { clearErrorMessage: true, ), ); - } catch (e) { + } on HtHttpException catch (e) { emit( state.copyWith( dataLoadingStatus: SourceFilterDataLoadingStatus.failure, - errorMessage: 'Failed to load filter criteria.', + error: e, ), ); } @@ -193,12 +192,9 @@ class SourcesFilterBloc extends Bloc { return allSources.where((source) { final matchesCountry = selectedCountries.isEmpty || - (source.headquarters != null && - selectedCountries.contains(source.headquarters!.isoCode)); + (selectedCountries.contains(source.headquarters.isoCode)); final matchesType = - selectedTypes.isEmpty || - (source.sourceType != null && - selectedTypes.contains(source.sourceType)); + selectedTypes.isEmpty || (selectedTypes.contains(source.sourceType)); return matchesCountry && matchesType; }).toList(); } diff --git a/lib/headlines-feed/bloc/sources_filter_state.dart b/lib/headlines-feed/bloc/sources_filter_state.dart index 73232da3..2dd05ed6 100644 --- a/lib/headlines-feed/bloc/sources_filter_state.dart +++ b/lib/headlines-feed/bloc/sources_filter_state.dart @@ -14,7 +14,7 @@ class SourcesFilterState extends Equatable { this.displayableSources = const [], this.finallySelectedSourceIds = const {}, this.dataLoadingStatus = SourceFilterDataLoadingStatus.initial, - this.errorMessage, + this.error, }); final List availableCountries; @@ -25,7 +25,7 @@ class SourcesFilterState extends Equatable { final List displayableSources; final Set finallySelectedSourceIds; final SourceFilterDataLoadingStatus dataLoadingStatus; - final String? errorMessage; + final HtHttpException? error; SourcesFilterState copyWith({ List? availableCountries, @@ -36,7 +36,7 @@ class SourcesFilterState extends Equatable { List? displayableSources, Set? finallySelectedSourceIds, SourceFilterDataLoadingStatus? dataLoadingStatus, - String? errorMessage, + HtHttpException? error, bool clearErrorMessage = false, }) { return SourcesFilterState( @@ -50,9 +50,7 @@ class SourcesFilterState extends Equatable { finallySelectedSourceIds: finallySelectedSourceIds ?? this.finallySelectedSourceIds, dataLoadingStatus: dataLoadingStatus ?? this.dataLoadingStatus, - errorMessage: clearErrorMessage - ? null - : errorMessage ?? this.errorMessage, + error: clearErrorMessage ? null : error ?? this.error, ); } @@ -66,6 +64,6 @@ class SourcesFilterState extends Equatable { displayableSources, finallySelectedSourceIds, dataLoadingStatus, - errorMessage, + error, ]; } diff --git a/lib/headlines-feed/bloc/topics_filter_bloc.dart b/lib/headlines-feed/bloc/topics_filter_bloc.dart new file mode 100644 index 00000000..e7784bb2 --- /dev/null +++ b/lib/headlines-feed/bloc/topics_filter_bloc.dart @@ -0,0 +1,105 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:equatable/equatable.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.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 [HtDataRepository]. +/// {@endtemplate} +class TopicsFilterBloc extends Bloc { + /// {@macro topics_filter_bloc} + /// + /// Requires a [HtDataRepository] to interact with the data layer. + TopicsFilterBloc({required HtDataRepository topicsRepository}) + : _topicsRepository = topicsRepository, + super(const TopicsFilterState()) { + on( + _onTopicsFilterRequested, + transformer: restartable(), + ); + on( + _onTopicsFilterLoadMoreRequested, + transformer: droppable(), + ); + } + + final HtDataRepository _topicsRepository; + + /// Number of topics to fetch per page. + static const _topicsLimit = 20; + + /// Handles the initial request to fetch topics. + Future _onTopicsFilterRequested( + TopicsFilterRequested event, + Emitter emit, + ) async { + // Prevent fetching if already loading or successful (unless forced refresh) + if (state.status == TopicsFilterStatus.loading || + state.status == TopicsFilterStatus.success) { + // 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), + ); + emit( + state.copyWith( + status: TopicsFilterStatus.success, + topics: response.items, + hasMore: response.hasMore, + cursor: response.cursor, + clearError: true, + ), + ); + } on HtHttpException 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, + ), + ); + 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 HtHttpException catch (e) { + // Keep existing data but indicate failure + emit(state.copyWith(status: TopicsFilterStatus.failure, error: e)); + } + } +} diff --git a/lib/headlines-feed/bloc/topics_filter_event.dart b/lib/headlines-feed/bloc/topics_filter_event.dart new file mode 100644 index 00000000..14d08ed7 --- /dev/null +++ b/lib/headlines-feed/bloc/topics_filter_event.dart @@ -0,0 +1,22 @@ +part of 'topics_filter_bloc.dart'; + +/// {@template topics_filter_event} +/// Base class for events related to fetching and managing topic filters. +/// {@endtemplate} +sealed class TopicsFilterEvent extends Equatable { + /// {@macro topics_filter_event} + const TopicsFilterEvent(); + + @override + List get props => []; +} + +/// {@template topics_filter_requested} +/// Event triggered to request the initial list of topics. +/// {@endtemplate} +final class TopicsFilterRequested extends TopicsFilterEvent {} + +/// {@template topics_filter_load_more_requested} +/// Event triggered to request the next page of topics for pagination. +/// {@endtemplate} +final class TopicsFilterLoadMoreRequested extends TopicsFilterEvent {} diff --git a/lib/headlines-feed/bloc/topics_filter_state.dart b/lib/headlines-feed/bloc/topics_filter_state.dart new file mode 100644 index 00000000..b8a1a41c --- /dev/null +++ b/lib/headlines-feed/bloc/topics_filter_state.dart @@ -0,0 +1,76 @@ +part of 'topics_filter_bloc.dart'; + +/// Enum representing the different statuses of the topic filter data fetching. +enum TopicsFilterStatus { + /// Initial state, no data loaded yet. + initial, + + /// Currently fetching the first page of topics. + loading, + + /// Successfully loaded topics. May be loading more in the background. + success, + + /// An error occurred while fetching topics. + failure, + + /// Loading more topics for pagination (infinity scroll). + loadingMore, +} + +/// {@template topics_filter_state} +/// Represents the state for the topic filter feature. +/// +/// Contains the list of fetched topics, pagination information, +/// loading/error status. +/// {@endtemplate} +final class TopicsFilterState extends Equatable { + /// {@macro topics_filter_state} + const TopicsFilterState({ + this.status = TopicsFilterStatus.initial, + this.topics = const [], + this.hasMore = true, + this.cursor, + this.error, + }); + + /// The current status of fetching topics. + final TopicsFilterStatus status; + + /// The list of [Topic] objects fetched so far. + final List topics; + + /// Flag indicating if there are more topics available to fetch. + final bool hasMore; + + /// The cursor string to fetch the next page of topics. + /// This is typically the ID of the last fetched topic. + final String? cursor; + + /// An optional error object if the status is [TopicsFilterStatus.failure]. + final HtHttpException? error; + + /// Creates a copy of this state with the given fields replaced. + TopicsFilterState copyWith({ + TopicsFilterStatus? status, + List? topics, + bool? hasMore, + String? cursor, + HtHttpException? error, + bool clearError = false, + bool clearCursor = false, + }) { + return TopicsFilterState( + status: status ?? this.status, + topics: topics ?? this.topics, + hasMore: hasMore ?? this.hasMore, + // Allow explicitly setting cursor to null or clearing it + cursor: clearCursor ? null : (cursor ?? this.cursor), + // Clear error if requested, otherwise keep existing or use new one + error: clearError ? null : error ?? this.error, + ); + } + + @override + List get props => [status, topics, hasMore, cursor, error]; +} diff --git a/lib/headlines-feed/models/headline_filter.dart b/lib/headlines-feed/models/headline_filter.dart index 6e8f4cb5..908dd14c 100644 --- a/lib/headlines-feed/models/headline_filter.dart +++ b/lib/headlines-feed/models/headline_filter.dart @@ -7,16 +7,16 @@ import 'package:ht_shared/ht_shared.dart'; class HeadlineFilter extends Equatable { /// {@macro headline_filter} const HeadlineFilter({ - this.categories, + this.topics, this.sources, this.selectedSourceCountryIsoCodes, this.selectedSourceSourceTypes, this.isFromFollowedItems = false, }); - /// The list of selected category filters. - /// Headlines matching *any* of these categories will be included (OR logic). - final List? categories; + /// The list of selected topic filters. + /// Headlines matching *any* of these topics will be included (OR logic). + final List? topics; /// The list of selected source filters. /// Headlines matching *any* of these sources will be included (OR logic). @@ -33,7 +33,7 @@ class HeadlineFilter extends Equatable { @override List get props => [ - categories, + topics, sources, selectedSourceCountryIsoCodes, selectedSourceSourceTypes, @@ -43,14 +43,14 @@ class HeadlineFilter extends Equatable { /// Creates a copy of this [HeadlineFilter] with the given fields /// replaced with the new values. HeadlineFilter copyWith({ - List? categories, + List? topics, List? sources, Set? selectedSourceCountryIsoCodes, Set? selectedSourceSourceTypes, bool? isFromFollowedItems, }) { return HeadlineFilter( - categories: categories ?? this.categories, + topics: topics ?? this.topics, sources: sources ?? this.sources, selectedSourceCountryIsoCodes: selectedSourceCountryIsoCodes ?? this.selectedSourceCountryIsoCodes, diff --git a/lib/headlines-feed/view/category_filter_page.dart b/lib/headlines-feed/view/category_filter_page.dart deleted file mode 100644 index 03142506..00000000 --- a/lib/headlines-feed/view/category_filter_page.dart +++ /dev/null @@ -1,261 +0,0 @@ -// -// ignore_for_file: lines_longer_than_80_chars - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import 'package:ht_main/headlines-feed/bloc/categories_filter_bloc.dart'; -import 'package:ht_main/l10n/l10n.dart'; -import 'package:ht_main/shared/constants/constants.dart'; -import 'package:ht_main/shared/widgets/widgets.dart'; -import 'package:ht_shared/ht_shared.dart' show Category; - -/// {@template category_filter_page} -/// A page dedicated to selecting news categories for filtering headlines. -/// -/// Uses [CategoriesFilterBloc] to fetch categories paginatively, allows -/// multiple selections, and returns the selected list via `context.pop` -/// when the user applies the changes. -/// {@endtemplate} -class CategoryFilterPage extends StatefulWidget { - /// {@macro category_filter_page} - const CategoryFilterPage({super.key}); - - @override - State createState() => _CategoryFilterPageState(); -} - -/// State for the [CategoryFilterPage]. -/// -/// Manages the local selection state ([_pageSelectedCategories]) and interacts -/// with [CategoriesFilterBloc] for data fetching and pagination. -class _CategoryFilterPageState extends State { - /// Stores the categories selected by the user *on this specific page*. - /// This state is local to the `CategoryFilterPage` lifecycle. - /// It's initialized in `initState` using the list of previously selected - /// categories 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 _pageSelectedCategories; - - /// Scroll controller to detect when the user reaches the end of the list - /// for pagination. - final _scrollController = ScrollController(); - - @override - void initState() { - super.initState(); - // Initialization needs to happen after the first frame to safely access - // GoRouterState.of(context). - WidgetsBinding.instance.addPostFrameCallback((_) { - // 1. Retrieve the list of categories 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 (`_pageSelectedCategories`) 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. - _pageSelectedCategories = Set.from(initialSelection ?? []); - - // 3. Trigger the page-specific BLoC (CategoriesFilterBloc) to start - // fetching the list of *all available* categories that the user can - // potentially select from. The BLoC handles fetching, pagination, - // loading states, and errors for the *list of options*. - context.read().add(CategoriesFilterRequested()); - }); - // Add listener for pagination logic. - _scrollController.addListener(_onScroll); - } - - @override - void dispose() { - _scrollController - ..removeListener(_onScroll) - ..dispose(); - super.dispose(); - } - - /// Callback function for scroll events. - /// - /// Checks if the user has scrolled near the bottom of the list and triggers - /// fetching more categories via the BLoC if available. - void _onScroll() { - if (!_scrollController.hasClients) return; - final maxScroll = _scrollController.position.maxScrollExtent; - final currentScroll = _scrollController.offset; - final bloc = context.read(); - // Fetch more when nearing the bottom, if BLoC has more and isn't already loading more - if (currentScroll >= (maxScroll * 0.9) && - bloc.state.hasMore && - bloc.state.status != CategoriesFilterStatus.loadingMore) { - bloc.add(CategoriesFilterLoadMoreRequested()); - } - } - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - - final theme = Theme.of(context); - final textTheme = theme.textTheme; - - return Scaffold( - appBar: AppBar( - title: Text( - l10n.headlinesFeedFilterCategoryLabel, - style: textTheme.titleLarge, - ), - actions: [ - 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 categories (`_pageSelectedCategories`) - // from this page back to the previous page (`HeadlinesFilterPage`). - // `HeadlinesFilterPage` receives this list in its `onResult` callback. - context.pop(_pageSelectedCategories.toList()); - }, - ), - ], - ), - // Use BlocBuilder to react to state changes from CategoriesFilterBloc - body: BlocBuilder( - builder: _buildBody, - ), - ); - } - - /// Builds the main content body based on the current [CategoriesFilterState]. - Widget _buildBody(BuildContext context, CategoriesFilterState state) { - final l10n = context.l10n; - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final colorScheme = theme.colorScheme; - - // Handle initial loading state - if (state.status == CategoriesFilterStatus.loading) { - return LoadingStateWidget( - icon: Icons.category_outlined, - headline: l10n.categoryFilterLoadingHeadline, - subheadline: l10n.categoryFilterLoadingSubheadline, - ); - } - - // Handle failure state (show error and retry button) - if (state.status == CategoriesFilterStatus.failure && - state.categories.isEmpty) { - return FailureStateWidget( - message: state.error?.toString() ?? l10n.unknownError, - onRetry: () => context.read().add( - CategoriesFilterRequested(), - ), - ); - } - - // Handle empty state (after successful load but no categories found) - if (state.status == CategoriesFilterStatus.success && - state.categories.isEmpty) { - return InitialStateWidget( - icon: Icons.search_off_outlined, - headline: l10n.categoryFilterEmptyHeadline, - subheadline: l10n.categoryFilterEmptySubheadline, - ); - } - - // Handle loaded state (success or loading more) - return ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.paddingSmall, - ).copyWith(bottom: AppSpacing.xxl), - itemCount: - state.categories.length + - ((state.status == CategoriesFilterStatus.loadingMore || - (state.status == CategoriesFilterStatus.failure && - state.categories.isNotEmpty)) - ? 1 - : 0), - itemBuilder: (context, index) { - if (index >= state.categories.length) { - if (state.status == CategoriesFilterStatus.loadingMore) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), - child: Center(child: CircularProgressIndicator()), - ); - } else if (state.status == CategoriesFilterStatus.failure) { - return Padding( - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.md, - horizontal: AppSpacing.lg, - ), - child: Center( - child: Text( - l10n.loadMoreError, - style: textTheme.bodySmall?.copyWith( - color: colorScheme.error, - ), - ), - ), - ); - } - return const SizedBox.shrink(); - } - - final category = state.categories[index]; - final isSelected = _pageSelectedCategories.contains(category); - - return CheckboxListTile( - title: Text(category.name, style: textTheme.titleMedium), - secondary: category.iconUrl != null - ? SizedBox( - width: AppSpacing.xl + AppSpacing.sm, - height: AppSpacing.xl + AppSpacing.sm, - child: ClipRRect( - borderRadius: BorderRadius.circular(AppSpacing.xs), - child: Image.network( - category.iconUrl!, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) => Icon( - Icons.category_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xl, - ), - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Center( - child: CircularProgressIndicator( - strokeWidth: 2, - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ); - }, - ), - ), - ) - : null, - value: isSelected, - onChanged: (bool? value) { - setState(() { - if (value == true) { - _pageSelectedCategories.add(category); - } else { - _pageSelectedCategories.remove(category); - } - }); - }, - controlAffinity: ListTileControlAffinity.leading, - contentPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingMedium, - ), - ); - }, - ); - } -} diff --git a/lib/headlines-feed/view/country_filter_page.dart b/lib/headlines-feed/view/country_filter_page.dart index ef575ff4..55a4786d 100644 --- a/lib/headlines-feed/view/country_filter_page.dart +++ b/lib/headlines-feed/view/country_filter_page.dart @@ -6,9 +6,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:ht_main/headlines-feed/bloc/countries_filter_bloc.dart'; import 'package:ht_main/l10n/l10n.dart'; -import 'package:ht_main/shared/constants/constants.dart'; -import 'package:ht_main/shared/widgets/widgets.dart'; -import 'package:ht_shared/ht_shared.dart' show Country; +import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template country_filter_page} /// A page dedicated to selecting event countries for filtering headlines. @@ -97,7 +96,7 @@ class _CountryFilterPageState extends State { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; @@ -130,7 +129,7 @@ class _CountryFilterPageState extends State { /// Builds the main content body based on the current [CountriesFilterState]. Widget _buildBody(BuildContext context, CountriesFilterState state) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; @@ -148,7 +147,7 @@ class _CountryFilterPageState extends State { if (state.status == CountriesFilterStatus.failure && state.countries.isEmpty) { return FailureStateWidget( - message: state.error?.toString() ?? l10n.unknownError, + exception: state.error ?? const UnknownException('Unknown error'), onRetry: () => context.read().add(CountriesFilterRequested()), ); diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index c4a64f1a..c8ed0e9b 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -6,11 +6,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:ht_main/app/bloc/app_bloc.dart'; import 'package:ht_main/headlines-feed/bloc/headlines_feed_bloc.dart'; -// HeadlineItemWidget import removed import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; import 'package:ht_main/shared/shared.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template headlines_feed_view} /// The core view widget for the headlines feed. @@ -55,13 +55,12 @@ class _HeadlinesFeedPageState extends State { /// [HeadlinesFeedFetchRequested] event to the BLoC. void _onScroll() { final state = context.read().state; - if (_isBottom && state is HeadlinesFeedLoaded) { - if (state.hasMore) { - // Request the next page of headlines - context.read().add( - HeadlinesFeedFetchRequested(cursor: state.cursor), - ); - } + if (_isBottom && + state.hasMore && + state.status != HeadlinesFeedStatus.loadingMore) { + context.read().add( + const HeadlinesFeedFetchRequested(), + ); } } @@ -79,7 +78,7 @@ class _HeadlinesFeedPageState extends State { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; @@ -101,14 +100,10 @@ class _HeadlinesFeedPageState extends State { // ), BlocBuilder( builder: (context, state) { - var isFilterApplied = false; - if (state is HeadlinesFeedLoaded) { - // Check if any filter list is non-null and not empty - isFilterApplied = - (state.filter.categories?.isNotEmpty ?? false) || - (state.filter.sources?.isNotEmpty ?? false); - // (state.filter.eventCountries?.isNotEmpty ?? false); - } + final isFilterApplied = + (state.filter.topics?.isNotEmpty ?? false) || + (state.filter.sources?.isNotEmpty ?? false) || + state.filter.isFromFollowedItems; return Stack( children: [ IconButton( @@ -144,225 +139,206 @@ class _HeadlinesFeedPageState extends State { ], ), body: BlocBuilder( - buildWhen: (previous, current) => - current is! HeadlinesFeedLoadingSilently, builder: (context, state) { - switch (state) { - case HeadlinesFeedInitial(): // Handle initial state - case HeadlinesFeedLoading(): - // Display full-screen loading indicator - return LoadingStateWidget( - icon: Icons.newspaper, - headline: l10n.headlinesFeedLoadingHeadline, - subheadline: l10n.headlinesFeedLoadingSubheadline, - ); + if (state.status == HeadlinesFeedStatus.initial || + (state.status == HeadlinesFeedStatus.loading && + state.feedItems.isEmpty)) { + return LoadingStateWidget( + icon: Icons.newspaper, + headline: l10n.headlinesFeedLoadingHeadline, + subheadline: l10n.headlinesFeedLoadingSubheadline, + ); + } - case HeadlinesFeedLoadingSilently(): - // This state is handled by buildWhen, should not be reached here. - // Return an empty container as a fallback. - return const SizedBox.shrink(); + if (state.status == HeadlinesFeedStatus.failure && + state.feedItems.isEmpty) { + return FailureStateWidget( + exception: state.error!, + onRetry: () => context.read().add( + HeadlinesFeedRefreshRequested(), + ), + ); + } - case HeadlinesFeedLoaded(): - if (state.feedItems.isEmpty) { - // Changed from state.headlines - return FailureStateWidget( - message: - '${l10n.headlinesFeedEmptyFilteredHeadline}\n${l10n.headlinesFeedEmptyFilteredSubheadline}', - onRetry: () { - context.read().add( - HeadlinesFeedFiltersCleared(), - ); - }, - retryButtonText: l10n.headlinesFeedClearFiltersButton, - ); - } - return RefreshIndicator( - onRefresh: () async { - context.read().add( - HeadlinesFeedRefreshRequested(), - ); - }, - child: ListView.separated( - controller: _scrollController, - padding: const EdgeInsets.only( - top: AppSpacing.md, - bottom: AppSpacing.xxl, - ), - itemCount: state.hasMore - ? state.feedItems.length + - 1 // Changed - : state.feedItems.length, - separatorBuilder: (context, index) { - // Add a bit more space if the next item is an Ad or AccountAction - if (index < state.feedItems.length - 1) { - final currentItem = state.feedItems[index]; - final nextItem = state.feedItems[index + 1]; - if ((currentItem is Headline && - (nextItem is Ad || nextItem is AccountAction)) || - ((currentItem is Ad || - currentItem is AccountAction) && - nextItem is Headline)) { - return const SizedBox(height: AppSpacing.md); - } - } - return const SizedBox(height: AppSpacing.lg); - }, - itemBuilder: (context, index) { - if (index >= state.feedItems.length) { - // Changed - return const Padding( - padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), - child: Center(child: CircularProgressIndicator()), - ); - } - final item = state.feedItems[index]; + if (state.status == HeadlinesFeedStatus.success && + state.feedItems.isEmpty) { + return FailureStateWidget( + exception: const UnknownException( + 'No headlines found matching your criteria.', + ), + onRetry: () => context.read().add( + HeadlinesFeedFiltersCleared(), + ), + retryButtonText: l10n.headlinesFeedClearFiltersButton, + ); + } - if (item is Headline) { - final imageStyle = context - .watch() - .state - .settings - .feedPreferences - .headlineImageStyle; - Widget tile; - switch (imageStyle) { - case HeadlineImageStyle.hidden: - tile = HeadlineTileTextOnly( - headline: item, - onHeadlineTap: () => context.goNamed( - Routes.articleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), - ); - case HeadlineImageStyle.smallThumbnail: - tile = HeadlineTileImageStart( - headline: item, - onHeadlineTap: () => context.goNamed( - Routes.articleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), - ); - case HeadlineImageStyle.largeThumbnail: - tile = HeadlineTileImageTop( - headline: item, - onHeadlineTap: () => context.goNamed( - Routes.articleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), - ); - } - return tile; - } else if (item is Ad) { - // Placeholder UI for Ad - return Card( - margin: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingMedium, - vertical: AppSpacing.xs, - ), - color: colorScheme.surfaceContainerHighest, - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - children: [ - if (item.imageUrl.isNotEmpty) - Image.network( - item.imageUrl, - height: 100, - errorBuilder: (ctx, err, st) => - const Icon(Icons.broken_image, size: 50), - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Placeholder Ad: ${item.adType?.name ?? 'Generic'}', - style: textTheme.titleSmall, - ), - Text( - 'Placement: ${item.placement?.name ?? 'Default'}', - style: textTheme.bodySmall, - ), - if (item.targetUrl.isNotEmpty) - TextButton( - onPressed: () { - // TODO: Launch URL - }, - child: const Text('Visit Advertiser'), - ), - ], + return RefreshIndicator( + onRefresh: () async { + context.read().add( + HeadlinesFeedRefreshRequested(), + ); + }, + child: ListView.separated( + controller: _scrollController, + padding: const EdgeInsets.only( + top: AppSpacing.md, + bottom: AppSpacing.xxl, + ), + itemCount: state.hasMore + ? state.feedItems.length + 1 + : state.feedItems.length, + separatorBuilder: (context, index) { + if (index < state.feedItems.length - 1) { + final currentItem = state.feedItems[index]; + final nextItem = state.feedItems[index + 1]; + if ((currentItem is Headline && + (nextItem is Ad || nextItem is FeedAction)) || + ((currentItem is Ad || currentItem is FeedAction) && + nextItem is Headline)) { + return const SizedBox(height: AppSpacing.md); + } + } + return const SizedBox(height: AppSpacing.lg); + }, + itemBuilder: (context, index) { + if (index >= state.feedItems.length) { + return state.status == HeadlinesFeedStatus.loadingMore + ? const Padding( + padding: EdgeInsets.symmetric( + vertical: AppSpacing.lg, ), + child: Center(child: CircularProgressIndicator()), + ) + : const SizedBox.shrink(); + } + final item = state.feedItems[index]; + + if (item is Headline) { + final imageStyle = context + .watch() + .state + .settings + .feedPreferences + .headlineImageStyle; + Widget tile; + switch (imageStyle) { + case HeadlineImageStyle.hidden: + tile = HeadlineTileTextOnly( + headline: item, + onHeadlineTap: () => context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': item.id}, + extra: item, ), ); - } else if (item is AccountAction) { - // Placeholder UI for AccountAction - return Card( - margin: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingMedium, - vertical: AppSpacing.xs, + case HeadlineImageStyle.smallThumbnail: + tile = HeadlineTileImageStart( + headline: item, + onHeadlineTap: () => context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': item.id}, + extra: item, ), - color: colorScheme.secondaryContainer, - child: ListTile( - leading: Icon( - item.accountActionType == - AccountActionType.linkAccount - ? Icons.link - : Icons.upgrade, - color: colorScheme.onSecondaryContainer, + ); + case HeadlineImageStyle.largeThumbnail: + tile = HeadlineTileImageTop( + headline: item, + onHeadlineTap: () => context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ), + ); + } + return tile; + } else if (item is Ad) { + return Card( + margin: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.xs, + ), + color: colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + children: [ + if (item.imageUrl.isNotEmpty) + Image.network( + item.imageUrl, + height: 100, + errorBuilder: (ctx, err, st) => + const Icon(Icons.broken_image, size: 50), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Placeholder Ad: ${item.adType}', + style: textTheme.titleSmall, + ), + Text( + 'Placement: ${item.placement}', + style: textTheme.bodySmall, ), - title: Text( - item.title, - style: textTheme.titleMedium?.copyWith( - color: colorScheme.onSecondaryContainer, - fontWeight: FontWeight.bold, + if (item.targetUrl.isNotEmpty) + TextButton( + onPressed: () { + // TODO(ht-development): Launch URL + }, + child: const Text('Visit Advertiser'), ), + ], + ), + ), + ); + } else if (item is FeedAction) { + return Card( + margin: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.xs, + ), + color: colorScheme.secondaryContainer, + child: ListTile( + leading: Icon( + item.feedActionType == FeedActionType.linkAccount + ? Icons.link + : Icons.upgrade, + color: colorScheme.onSecondaryContainer, + ), + title: Text( + item.title, + style: textTheme.titleMedium?.copyWith( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.bold, + ), + ), + subtitle: Text( + item.description, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSecondaryContainer.withOpacity( + 0.8, ), - subtitle: item.description != null - ? Text( - item.description!, - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSecondaryContainer - .withOpacity(0.8), - ), - ) - : null, - trailing: item.callToActionText != null - ? ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.secondary, - foregroundColor: colorScheme.onSecondary, - ), - onPressed: () { - if (item.callToActionUrl != null) { - context.push(item.callToActionUrl!); - } - }, - child: Text(item.callToActionText!), - ) - : null, - isThreeLine: - item.description != null && - item.description!.length > 50, ), - ); - } - return const SizedBox.shrink(); - }, - ), - ); - - case HeadlinesFeedError(): - // Display error message with a retry button - return FailureStateWidget( - message: state.message, - onRetry: () { - // Dispatch refresh event on retry - context.read().add( - HeadlinesFeedRefreshRequested(), + ), + trailing: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.secondary, + foregroundColor: colorScheme.onSecondary, + ), + onPressed: () { + if (item.callToActionUrl.isNotEmpty) { + context.push(item.callToActionUrl); + } + }, + child: Text(item.callToActionText), + ), + isThreeLine: item.description.length > 50, + ), ); - }, - ); - } + } + return const SizedBox.shrink(); + }, + ), + ); }, ), ); diff --git a/lib/headlines-feed/view/headlines_filter_page.dart b/lib/headlines-feed/view/headlines_filter_page.dart index 9a1e3f73..158843df 100644 --- a/lib/headlines-feed/view/headlines_filter_page.dart +++ b/lib/headlines-feed/view/headlines_filter_page.dart @@ -10,8 +10,8 @@ import 'package:ht_main/headlines-feed/bloc/headlines_feed_bloc.dart'; import 'package:ht_main/headlines-feed/models/headline_filter.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; -import 'package:ht_main/shared/constants/constants.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; // Keys for passing data to/from SourceFilterPage const String keySelectedSources = 'selectedSources'; @@ -39,7 +39,7 @@ class _HeadlinesFilterPageState extends State { /// 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 _tempSelectedCategories; + late List _tempSelectedTopics; late List _tempSelectedSources; late Set _tempSelectedSourceCountryIsoCodes; late Set _tempSelectedSourceSourceTypes; @@ -57,29 +57,17 @@ class _HeadlinesFilterPageState extends State { context, ).state; - var initialUseFollowedFilters = false; - - if (headlinesFeedState is HeadlinesFeedLoaded) { - final currentFilter = headlinesFeedState.filter; - _tempSelectedCategories = List.from(currentFilter.categories ?? []); - _tempSelectedSources = List.from(currentFilter.sources ?? []); - _tempSelectedSourceCountryIsoCodes = Set.from( - currentFilter.selectedSourceCountryIsoCodes ?? {}, - ); - _tempSelectedSourceSourceTypes = Set.from( - currentFilter.selectedSourceSourceTypes ?? {}, - ); - - // Use the new flag from the filter to set the checkbox state - initialUseFollowedFilters = currentFilter.isFromFollowedItems; - } else { - _tempSelectedCategories = []; - _tempSelectedSources = []; - _tempSelectedSourceCountryIsoCodes = {}; - _tempSelectedSourceSourceTypes = {}; - } + final currentFilter = headlinesFeedState.filter; + _tempSelectedTopics = List.from(currentFilter.topics ?? []); + _tempSelectedSources = List.from(currentFilter.sources ?? []); + _tempSelectedSourceCountryIsoCodes = Set.from( + currentFilter.selectedSourceCountryIsoCodes ?? {}, + ); + _tempSelectedSourceSourceTypes = Set.from( + currentFilter.selectedSourceSourceTypes ?? {}, + ); - _useFollowedFilters = initialUseFollowedFilters; + _useFollowedFilters = currentFilter.isFromFollowedItems; _isLoadingFollowedFilters = false; _loadFollowedFiltersError = null; _currentUserPreferences = null; @@ -110,8 +98,9 @@ class _HeadlinesFilterPageState extends State { setState(() { _isLoadingFollowedFilters = false; _useFollowedFilters = false; - _loadFollowedFiltersError = - context.l10n.mustBeLoggedInToUseFeatureError; + _loadFollowedFiltersError = AppLocalizationsX( + context, + ).l10n.mustBeLoggedInToUseFeatureError; }); return; } @@ -125,12 +114,12 @@ class _HeadlinesFilterPageState extends State { ); // NEW: Check if followed items are empty - if (preferences.followedCategories.isEmpty && + if (preferences.followedTopics.isEmpty && preferences.followedSources.isEmpty) { setState(() { _isLoadingFollowedFilters = false; _useFollowedFilters = false; - _tempSelectedCategories = []; + _tempSelectedTopics = []; _tempSelectedSources = []; }); if (mounted) { @@ -138,7 +127,11 @@ class _HeadlinesFilterPageState extends State { ..hideCurrentSnackBar() ..showSnackBar( SnackBar( - content: Text(context.l10n.noFollowedItemsForFilterSnackbar), + content: Text( + AppLocalizationsX( + context, + ).l10n.noFollowedItemsForFilterSnackbar, + ), duration: const Duration(seconds: 3), ), ); @@ -147,17 +140,23 @@ class _HeadlinesFilterPageState extends State { } else { setState(() { _currentUserPreferences = preferences; - _tempSelectedCategories = List.from(preferences.followedCategories); + _tempSelectedTopics = List.from(preferences.followedTopics); _tempSelectedSources = List.from(preferences.followedSources); // We don't auto-apply source country/type filters from user preferences here - // as the "Apply my followed" checkbox is primarily for categories/sources. + // as the "Apply my followed" checkbox is primarily for topics/sources. _isLoadingFollowedFilters = false; }); } } on NotFoundException { setState(() { - _currentUserPreferences = UserContentPreferences(id: currentUser.id); - _tempSelectedCategories = []; + _currentUserPreferences = UserContentPreferences( + id: currentUser.id, + followedTopics: const [], + followedSources: const [], + followedCountries: const [], + savedHeadlines: const [], + ); + _tempSelectedTopics = []; _tempSelectedSources = []; _isLoadingFollowedFilters = false; _useFollowedFilters = false; @@ -167,7 +166,11 @@ class _HeadlinesFilterPageState extends State { ..hideCurrentSnackBar() ..showSnackBar( SnackBar( - content: Text(context.l10n.noFollowedItemsForFilterSnackbar), + content: Text( + AppLocalizationsX( + context, + ).l10n.noFollowedItemsForFilterSnackbar, + ), duration: const Duration(seconds: 3), ), ); @@ -182,14 +185,16 @@ class _HeadlinesFilterPageState extends State { setState(() { _isLoadingFollowedFilters = false; _useFollowedFilters = false; - _loadFollowedFiltersError = context.l10n.unknownError; + _loadFollowedFiltersError = AppLocalizationsX( + context, + ).l10n.unknownError; }); } } void _clearTemporaryFilters() { setState(() { - _tempSelectedCategories = []; + _tempSelectedTopics = []; _tempSelectedSources = []; // Keep source country/type filters as they are not part of this quick filter // _tempSelectedSourceCountryIsoCodes = {}; @@ -218,7 +223,7 @@ class _HeadlinesFilterPageState extends State { required void Function(dynamic)? onResult, bool enabled = true, }) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final allLabel = l10n.headlinesFeedFilterAllLabel; final selectedLabel = l10n.headlinesFeedFilterSelectedCountLabel( selectedCount, @@ -248,7 +253,7 @@ class _HeadlinesFilterPageState extends State { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); return Scaffold( @@ -282,8 +287,8 @@ class _HeadlinesFilterPageState extends State { tooltip: l10n.headlinesFeedFilterApplyButton, onPressed: () { final newFilter = HeadlineFilter( - categories: _tempSelectedCategories.isNotEmpty - ? _tempSelectedCategories + topics: _tempSelectedTopics.isNotEmpty + ? _tempSelectedTopics : null, sources: _tempSelectedSources.isNotEmpty ? _tempSelectedSources @@ -352,14 +357,14 @@ class _HeadlinesFilterPageState extends State { const Divider(), _buildFilterTile( context: context, - title: l10n.headlinesFeedFilterCategoryLabel, + title: l10n.headlinesFeedFilterTopicLabel, enabled: !_useFollowedFilters && !_isLoadingFollowedFilters, - selectedCount: _tempSelectedCategories.length, - routeName: Routes.feedFilterCategoriesName, - currentSelectionData: _tempSelectedCategories, + selectedCount: _tempSelectedTopics.length, + routeName: Routes.feedFilterTopicsName, + currentSelectionData: _tempSelectedTopics, onResult: (result) { - if (result is List) { - setState(() => _tempSelectedCategories = result); + if (result is List) { + setState(() => _tempSelectedTopics = result); } }, ), diff --git a/lib/headlines-feed/view/source_filter_page.dart b/lib/headlines-feed/view/source_filter_page.dart index d68b9950..7e41a051 100644 --- a/lib/headlines-feed/view/source_filter_page.dart +++ b/lib/headlines-feed/view/source_filter_page.dart @@ -8,10 +8,8 @@ import 'package:ht_main/headlines-feed/view/headlines_filter_page.dart' show keySelectedCountryIsoCodes, keySelectedSourceTypes, keySelectedSources; import 'package:ht_main/l10n/app_localizations.dart'; import 'package:ht_main/l10n/l10n.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; -import 'package:ht_main/shared/widgets/failure_state_widget.dart'; -import 'package:ht_main/shared/widgets/loading_state_widget.dart'; -import 'package:ht_shared/ht_shared.dart' show Country, Source, SourceType; +import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; // Keys are defined in headlines_filter_page.dart and imported by router.dart // const String keySelectedSources = 'selectedSources'; @@ -54,7 +52,7 @@ class _SourceFilterView extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; final state = context.watch().state; @@ -117,7 +115,9 @@ class _SourceFilterView extends StatelessWidget { state.allAvailableSources.isEmpty) { // Check allAvailableSources return FailureStateWidget( - message: state.errorMessage ?? l10n.headlinesFeedFilterErrorCriteria, + exception: + state.error ?? + const UnknownException('Failed to load source filter data.'), onRetry: () { context.read().add(const LoadSourceFilterData()); }, @@ -283,7 +283,9 @@ class _SourceFilterView extends StatelessWidget { if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.failure && state.displayableSources.isEmpty) { return FailureStateWidget( - message: state.errorMessage ?? l10n.headlinesFeedFilterErrorSources, + exception: + state.error ?? + const UnknownException('Failed to load displayable sources.'), onRetry: () { context.read().add(const LoadSourceFilterData()); }, diff --git a/lib/headlines-feed/view/topic_filter_page.dart b/lib/headlines-feed/view/topic_filter_page.dart new file mode 100644 index 00000000..5a44013c --- /dev/null +++ b/lib/headlines-feed/view/topic_filter_page.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_main/headlines-feed/bloc/topics_filter_bloc.dart'; +import 'package:ht_main/l10n/l10n.dart'; +import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; + +class TopicFilterPage extends StatefulWidget { + 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((_) { + 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; + return Scaffold( + appBar: AppBar( + title: Text(l10n.headlinesFeedFilterTopicLabel), + actions: [ + IconButton( + icon: const Icon(Icons.check), + tooltip: l10n.headlinesFeedFilterApplyButton, + onPressed: () { + context.pop(_pageSelectedTopics.toList()); + }, + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + switch (state.status) { + case TopicsFilterStatus.initial: + case TopicsFilterStatus.loading: + return LoadingStateWidget( + icon: Icons.category_outlined, + headline: l10n.topicFilterLoadingHeadline, + subheadline: l10n.pleaseWait, + ); + case TopicsFilterStatus.failure: + return Center( + child: FailureStateWidget( + exception: + state.error ?? + const UnknownException( + 'An unknown error occurred while fetching topics.', + ), + onRetry: () => _topicsFilterBloc.add(TopicsFilterRequested()), + ), + ); + case TopicsFilterStatus.success: + case TopicsFilterStatus.loadingMore: + if (state.topics.isEmpty) { + return InitialStateWidget( + icon: Icons.category_outlined, + headline: l10n.topicFilterEmptyHeadline, + subheadline: l10n.topicFilterEmptySubheadline, + ); + } + return ListView.builder( + controller: _scrollController, + itemCount: state.hasMore + ? state.topics.length + 1 + : state.topics.length, + itemBuilder: (context, index) { + if (index >= state.topics.length) { + return const Center(child: CircularProgressIndicator()); + } + final topic = state.topics[index]; + final isSelected = _pageSelectedTopics.contains(topic); + return CheckboxListTile( + title: Text(topic.name), + value: isSelected, + onChanged: (bool? value) { + setState(() { + if (value == true) { + _pageSelectedTopics.add(topic); + } else { + _pageSelectedTopics.remove(topic); + } + }); + }, + ); + }, + ); + } + }, + ), + ); + } +} diff --git a/lib/headlines-search/bloc/headlines_search_bloc.dart b/lib/headlines-search/bloc/headlines_search_bloc.dart index 6d816aa3..45b0f36d 100644 --- a/lib/headlines-search/bloc/headlines_search_bloc.dart +++ b/lib/headlines-search/bloc/headlines_search_bloc.dart @@ -1,9 +1,10 @@ +// ignore_for_file: no_default_cases + import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:equatable/equatable.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_main/app/bloc/app_bloc.dart'; -import 'package:ht_main/headlines-search/models/search_model_type.dart'; import 'package:ht_main/shared/services/feed_injector_service.dart'; import 'package:ht_shared/ht_shared.dart'; @@ -14,12 +15,12 @@ class HeadlinesSearchBloc extends Bloc { HeadlinesSearchBloc({ required HtDataRepository headlinesRepository, - required HtDataRepository categoryRepository, + required HtDataRepository topicRepository, required HtDataRepository sourceRepository, required AppBloc appBloc, required FeedInjectorService feedInjectorService, }) : _headlinesRepository = headlinesRepository, - _categoryRepository = categoryRepository, + _topicRepository = topicRepository, _sourceRepository = sourceRepository, _appBloc = appBloc, _feedInjectorService = feedInjectorService, @@ -32,7 +33,7 @@ class HeadlinesSearchBloc } final HtDataRepository _headlinesRepository; - final HtDataRepository _categoryRepository; + final HtDataRepository _topicRepository; final HtDataRepository _sourceRepository; final AppBloc _appBloc; final FeedInjectorService _feedInjectorService; @@ -84,16 +85,18 @@ class HeadlinesSearchBloc try { PaginatedResponse response; switch (modelType) { - case SearchModelType.headline: - response = await _headlinesRepository.readAllByQuery( - {'q': searchTerm, 'model': modelType.toJson()}, - limit: _limit, - startAfterId: successState.cursor, + case ContentType.headline: + response = await _headlinesRepository.readAll( + filter: {'q': searchTerm}, + pagination: PaginationOptions( + limit: _limit, + cursor: successState.cursor, + ), ); // Cast to List for the injector final headlines = response.items.cast(); final currentUser = _appBloc.state.user; - final appConfig = _appBloc.state.appConfig; + final appConfig = _appBloc.state.remoteConfig; if (appConfig == null) { emit( successState.copyWith( @@ -106,7 +109,7 @@ class HeadlinesSearchBloc final injectedItems = _feedInjectorService.injectItems( headlines: headlines, user: currentUser, - appConfig: appConfig, + remoteConfig: appConfig, currentFeedItemCount: successState.items.length, ); emit( @@ -117,17 +120,26 @@ class HeadlinesSearchBloc ), ); // Dispatch event if AccountAction was injected during pagination - if (injectedItems.any((item) => item is AccountAction) && + if (injectedItems.any((item) => item is FeedAction) && _appBloc.state.user?.id != null) { + final feedAction = + injectedItems.firstWhere((item) => item is FeedAction) + as FeedAction; _appBloc.add( - AppUserAccountActionShown(userId: _appBloc.state.user!.id), + AppUserAccountActionShown( + userId: _appBloc.state.user!.id, + feedActionType: feedAction.feedActionType, + isCompleted: false, + ), ); } - case SearchModelType.category: - response = await _categoryRepository.readAllByQuery( - {'q': searchTerm, 'model': modelType.toJson()}, - limit: _limit, - startAfterId: successState.cursor, + case ContentType.topic: + response = await _topicRepository.readAll( + filter: {'q': searchTerm}, + pagination: PaginationOptions( + limit: _limit, + cursor: successState.cursor, + ), ); emit( successState.copyWith( @@ -138,11 +150,13 @@ class HeadlinesSearchBloc ), ); // Added break - case SearchModelType.source: - response = await _sourceRepository.readAllByQuery( - {'q': searchTerm, 'model': modelType.toJson()}, - limit: _limit, - startAfterId: successState.cursor, + case ContentType.source: + response = await _sourceRepository.readAll( + filter: {'q': searchTerm}, + pagination: PaginationOptions( + limit: _limit, + cursor: successState.cursor, + ), ); emit( successState.copyWith( @@ -153,6 +167,12 @@ class HeadlinesSearchBloc ), ); // Added break + default: + response = const PaginatedResponse( + items: [], + cursor: null, + hasMore: false, + ); } } on HtHttpException catch (e) { emit(successState.copyWith(errorMessage: e.message)); @@ -178,14 +198,14 @@ class HeadlinesSearchBloc List processedItems; switch (modelType) { - case SearchModelType.headline: - rawResponse = await _headlinesRepository.readAllByQuery({ - 'q': searchTerm, - 'model': modelType.toJson(), - }, limit: _limit); + case ContentType.headline: + rawResponse = await _headlinesRepository.readAll( + filter: {'q': searchTerm}, + pagination: const PaginationOptions(limit: _limit), + ); final headlines = rawResponse.items.cast(); final currentUser = _appBloc.state.user; - final appConfig = _appBloc.state.appConfig; + final appConfig = _appBloc.state.remoteConfig; if (appConfig == null) { emit( HeadlinesSearchFailure( @@ -199,21 +219,29 @@ class HeadlinesSearchBloc processedItems = _feedInjectorService.injectItems( headlines: headlines, user: currentUser, - appConfig: appConfig, + remoteConfig: appConfig, currentFeedItemCount: 0, ); - case SearchModelType.category: - rawResponse = await _categoryRepository.readAllByQuery({ - 'q': searchTerm, - 'model': modelType.toJson(), - }, limit: _limit); + case ContentType.topic: + rawResponse = await _topicRepository.readAll( + filter: {'q': searchTerm}, + pagination: const PaginationOptions(limit: _limit), + ); processedItems = rawResponse.items.cast(); - case SearchModelType.source: - rawResponse = await _sourceRepository.readAllByQuery({ - 'q': searchTerm, - 'model': modelType.toJson(), - }, limit: _limit); + case ContentType.source: + rawResponse = await _sourceRepository.readAll( + filter: {'q': searchTerm}, + pagination: const PaginationOptions(limit: _limit), + ); processedItems = rawResponse.items.cast(); + default: + // Handle unexpected content types if necessary + rawResponse = const PaginatedResponse( + items: [], + cursor: null, + hasMore: false, + ); + processedItems = []; } emit( HeadlinesSearchSuccess( @@ -224,12 +252,19 @@ class HeadlinesSearchBloc selectedModelType: modelType, ), ); - // Dispatch event if AccountAction was injected in new search - if (modelType == SearchModelType.headline && - processedItems.any((item) => item is AccountAction) && + // Dispatch event if Feed Action was injected in new search + if (modelType == ContentType.headline && + processedItems.any((item) => item is FeedAction) && _appBloc.state.user?.id != null) { + final feedAction = + processedItems.firstWhere((item) => item is FeedAction) + as FeedAction; _appBloc.add( - AppUserAccountActionShown(userId: _appBloc.state.user!.id), + AppUserAccountActionShown( + userId: _appBloc.state.user!.id, + feedActionType: feedAction.feedActionType, + isCompleted: false, + ), ); } } on HtHttpException catch (e) { diff --git a/lib/headlines-search/bloc/headlines_search_event.dart b/lib/headlines-search/bloc/headlines_search_event.dart index ab1a105d..3b3af957 100644 --- a/lib/headlines-search/bloc/headlines_search_event.dart +++ b/lib/headlines-search/bloc/headlines_search_event.dart @@ -10,7 +10,7 @@ sealed class HeadlinesSearchEvent extends Equatable { final class HeadlinesSearchModelTypeChanged extends HeadlinesSearchEvent { const HeadlinesSearchModelTypeChanged(this.newModelType); - final SearchModelType newModelType; + final ContentType newModelType; @override List get props => [newModelType]; diff --git a/lib/headlines-search/bloc/headlines_search_state.dart b/lib/headlines-search/bloc/headlines_search_state.dart index c97a53de..7f04301b 100644 --- a/lib/headlines-search/bloc/headlines_search_state.dart +++ b/lib/headlines-search/bloc/headlines_search_state.dart @@ -1,11 +1,9 @@ part of 'headlines_search_bloc.dart'; abstract class HeadlinesSearchState extends Equatable { - const HeadlinesSearchState({ - this.selectedModelType = SearchModelType.headline, - }); + const HeadlinesSearchState({this.selectedModelType = ContentType.headline}); - final SearchModelType selectedModelType; + final ContentType selectedModelType; @override List get props => [selectedModelType]; @@ -51,7 +49,7 @@ class HeadlinesSearchSuccess extends HeadlinesSearchState { String? cursor, String? errorMessage, String? lastSearchTerm, - SearchModelType? selectedModelType, + ContentType? selectedModelType, bool clearErrorMessage = false, }) { return HeadlinesSearchSuccess( diff --git a/lib/headlines-search/models/search_model_type.dart b/lib/headlines-search/models/search_model_type.dart deleted file mode 100644 index d2f7208b..00000000 --- a/lib/headlines-search/models/search_model_type.dart +++ /dev/null @@ -1,28 +0,0 @@ -/// Defines the types of models that can be searched. -enum SearchModelType { - headline, - category, - // country, - source; - - /// Returns a user-friendly display name for the enum value. - /// - /// This should ideally be localized using context.l10n, - /// but for simplicity in this step, we'll use direct strings. - /// TODO(fulleni): Localize these display names. - String get displayName { - switch (this) { - case SearchModelType.headline: - return 'Headlines'; - case SearchModelType.category: - return 'Categories'; - // case SearchModelType.country: // Removed - // return 'Countries'; - case SearchModelType.source: - return 'Sources'; - } - } - - /// Returns the string representation for API query parameters. - String toJson() => name; -} diff --git a/lib/headlines-search/view/headlines_search_page.dart b/lib/headlines-search/view/headlines_search_page.dart index c023a0ca..6c7f221b 100644 --- a/lib/headlines-search/view/headlines_search_page.dart +++ b/lib/headlines-search/view/headlines_search_page.dart @@ -7,16 +7,14 @@ import 'package:go_router/go_router.dart'; import 'package:ht_main/app/bloc/app_bloc.dart'; // HeadlineItemWidget import removed import 'package:ht_main/headlines-search/bloc/headlines_search_bloc.dart'; -import 'package:ht_main/headlines-search/models/search_model_type.dart'; -// Import new item widgets -import 'package:ht_main/headlines-search/widgets/category_item_widget.dart'; // import 'package:ht_main/headlines-search/widgets/country_item_widget.dart'; import 'package:ht_main/headlines-search/widgets/source_item_widget.dart'; -import 'package:ht_main/l10n/app_localizations.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; +import 'package:ht_main/shared/extensions/content_type_extensions.dart'; import 'package:ht_main/shared/shared.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// Page widget responsible for providing the BLoC for the headlines search feature. class HeadlinesSearchPage extends StatelessWidget { @@ -46,7 +44,7 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { final _scrollController = ScrollController(); final _textController = TextEditingController(); bool _showClearButton = false; - SearchModelType _selectedModelType = SearchModelType.headline; + ContentType _selectedModelType = ContentType.headline; @override void initState() { @@ -57,9 +55,15 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { _showClearButton = _textController.text.isNotEmpty; }); }); - // Ensure _selectedModelType is valid (it should be, as .country is removed from enum) - if (!SearchModelType.values.contains(_selectedModelType)) { - _selectedModelType = SearchModelType.headline; + // TODO(user): This logic might need adjustment if not all ContentType values are searchable. + // For now, we default to headline if the current selection is not in the allowed list. + final searchableTypes = [ + ContentType.headline, + ContentType.topic, + ContentType.source, + ]; + if (!searchableTypes.contains(_selectedModelType)) { + _selectedModelType = ContentType.headline; } context.read().add( HeadlinesSearchModelTypeChanged(_selectedModelType), @@ -99,16 +103,21 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); final colorScheme = theme.colorScheme; final textTheme = theme.textTheme; final appBarTheme = theme.appBarTheme; - final availableSearchModelTypes = SearchModelType.values.toList(); + // TODO(user): Replace this with a filtered list of searchable content types. + final availableSearchModelTypes = [ + ContentType.headline, + ContentType.topic, + ContentType.source, + ]; if (!availableSearchModelTypes.contains(_selectedModelType)) { - _selectedModelType = SearchModelType.headline; + _selectedModelType = ContentType.headline; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { context.read().add( @@ -127,7 +136,7 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { children: [ SizedBox( width: 150, - child: DropdownButtonFormField( + child: DropdownButtonFormField( value: _selectedModelType, decoration: const InputDecoration( border: InputBorder.none, @@ -149,22 +158,14 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { appBarTheme.iconTheme?.color ?? colorScheme.onSurfaceVariant, ), - items: availableSearchModelTypes.map((SearchModelType type) { - String displayLocalizedName; - switch (type) { - case SearchModelType.headline: - displayLocalizedName = l10n.searchModelTypeHeadline; - case SearchModelType.category: - displayLocalizedName = l10n.searchModelTypeCategory; - case SearchModelType.source: - displayLocalizedName = l10n.searchModelTypeSource; - } - return DropdownMenuItem( + // TODO(user): Use the new localization extension here. + items: availableSearchModelTypes.map((ContentType type) { + return DropdownMenuItem( value: type, - child: Text(displayLocalizedName), + child: Text(type.displayName(context)), ); }).toList(), - onChanged: (SearchModelType? newValue) { + onChanged: (ContentType? newValue) { if (newValue != null) { setState(() { _selectedModelType = newValue; @@ -185,7 +186,8 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { controller: _textController, style: appBarTheme.titleTextStyle ?? textTheme.titleMedium, decoration: InputDecoration( - hintText: _getHintTextForModelType(_selectedModelType, l10n), + // TODO(user): Create a similar localization extension for hint text. + hintText: 'Search...', hintStyle: textTheme.bodyMedium?.copyWith( color: (appBarTheme.titleTextStyle?.color ?? @@ -242,8 +244,9 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { // Use LoadingStateWidget icon: Icons.search_outlined, headline: l10n.headlinesFeedLoadingHeadline, - subheadline: - 'Searching ${state.selectedModelType.displayName.toLowerCase()}...', + subheadline: l10n.searchingFor( + state.selectedModelType.displayName(context).toLowerCase(), + ), ), HeadlinesSearchSuccess( items: final items, @@ -254,7 +257,7 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ) => errorMessage != null ? FailureStateWidget( - message: errorMessage, + exception: UnknownException(errorMessage), onRetry: () => context.read().add( HeadlinesSearchFetchRequested( searchTerm: lastSearchTerm, @@ -262,11 +265,12 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ), ) : items.isEmpty - ? FailureStateWidget( - // Use FailureStateWidget for no results - message: - '${l10n.headlinesSearchNoResultsHeadline} for "$lastSearchTerm" in ${resultsModelType.displayName.toLowerCase()}.\n${l10n.headlinesSearchNoResultsSubheadline}', - // No retry button for "no results" + ? InitialStateWidget( + // Use InitialStateWidget for no results as it's not a failure + icon: Icons.search_off_outlined, + headline: l10n.headlinesSearchNoResultsHeadline, + subheadline: + 'For "$lastSearchTerm" in ${resultsModelType.displayName(context).toLowerCase()}.\n${l10n.headlinesSearchNoResultsSubheadline}', ) : ListView.separated( controller: _scrollController, @@ -327,8 +331,9 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ); } return tile; - } else if (feedItem is Category) { - return CategoryItemWidget(category: feedItem); + } else if (feedItem is Topic) { + // TODO(user): Create a TopicItemWidget similar to CategoryItemWidget + return ListTile(title: Text(feedItem.name)); } else if (feedItem is Source) { return SourceItemWidget(source: feedItem); } else if (feedItem is Ad) { @@ -361,18 +366,18 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ), const SizedBox(height: AppSpacing.sm), Text( - 'Placeholder Ad: ${feedItem.adType?.name ?? 'Generic'}', + 'Placeholder Ad: ${feedItem.adType.name}', style: currentTextTheme.titleSmall, ), Text( - 'Placement: ${feedItem.placement?.name ?? 'Default'}', + 'Placement: ${feedItem.placement.name}', style: currentTextTheme.bodySmall, ), ], ), ), ); - } else if (feedItem is AccountAction) { + } else if (feedItem is FeedAction) { return Card( margin: const EdgeInsets.symmetric( vertical: AppSpacing.xs, @@ -380,8 +385,8 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { color: currentColorScheme.secondaryContainer, child: ListTile( leading: Icon( - feedItem.accountActionType == - AccountActionType.linkAccount + feedItem.feedActionType == + FeedActionType.linkAccount ? Icons .link_outlined // Outlined : Icons.upgrade_outlined, @@ -395,9 +400,9 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { fontWeight: FontWeight.bold, ), ), - subtitle: feedItem.description != null + subtitle: feedItem.description.isNotEmpty ? Text( - feedItem.description!, + feedItem.description, style: currentTextTheme.bodySmall ?.copyWith( color: currentColorScheme @@ -406,7 +411,7 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ), ) : null, - trailing: feedItem.callToActionText != null + trailing: feedItem.callToActionText.isNotEmpty ? ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: @@ -421,18 +426,12 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { textStyle: currentTextTheme.labelLarge, ), onPressed: () { - if (feedItem.callToActionUrl != null) { - context.push( - feedItem.callToActionUrl!, - ); - } + context.push(feedItem.callToActionUrl); }, - child: Text(feedItem.callToActionText!), + child: Text(feedItem.callToActionText), ) : null, - isThreeLine: - feedItem.description != null && - feedItem.description!.length > 50, + isThreeLine: feedItem.description.length > 50, contentPadding: const EdgeInsets.symmetric( // Consistent padding horizontal: AppSpacing.paddingMedium, @@ -450,8 +449,9 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { selectedModelType: final failedModelType, ) => FailureStateWidget( - message: - 'Failed to search "$lastSearchTerm" in ${failedModelType.displayName.toLowerCase()}:\n$errorMessage', + exception: UnknownException( + 'Failed to search "$lastSearchTerm" in ${failedModelType.displayName(context).toLowerCase()}:\n$errorMessage', + ), onRetry: () => context.read().add( HeadlinesSearchFetchRequested(searchTerm: lastSearchTerm), ), @@ -462,19 +462,4 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { ), ); } - - String _getHintTextForModelType( - SearchModelType modelType, - AppLocalizations l10n, - ) { - // The switch is now exhaustive for the remaining SearchModelType values - switch (modelType) { - case SearchModelType.headline: - return l10n.searchHintTextHeadline; - case SearchModelType.category: - return l10n.searchHintTextCategory; - case SearchModelType.source: - return l10n.searchHintTextSource; - } - } } diff --git a/lib/headlines-search/widgets/category_item_widget.dart b/lib/headlines-search/widgets/category_item_widget.dart index 6ac2267d..4376985c 100644 --- a/lib/headlines-search/widgets/category_item_widget.dart +++ b/lib/headlines-search/widgets/category_item_widget.dart @@ -4,27 +4,27 @@ import 'package:ht_main/entity_details/view/entity_details_page.dart'; import 'package:ht_main/router/routes.dart'; import 'package:ht_shared/ht_shared.dart'; -/// A simple widget to display a Category search result. -class CategoryItemWidget extends StatelessWidget { - const CategoryItemWidget({required this.category, super.key}); +/// A simple widget to display a Topic search result. +class TopicItemWidget extends StatelessWidget { + const TopicItemWidget({required this.topic, super.key}); - final Category category; + final Topic topic; @override Widget build(BuildContext context) { return ListTile( - title: Text(category.name), - subtitle: category.description != null + title: Text(topic.name), + subtitle: topic.description.isNotEmpty ? Text( - category.description!, + topic.description, maxLines: 2, overflow: TextOverflow.ellipsis, ) : null, onTap: () { context.push( - Routes.categoryDetails, - extra: EntityDetailsPageArguments(entity: category), + Routes.topicDetails, + extra: EntityDetailsPageArguments(entity: topic), ); }, ); diff --git a/lib/headlines-search/widgets/source_item_widget.dart b/lib/headlines-search/widgets/source_item_widget.dart index 08e00fd2..37a34ccd 100644 --- a/lib/headlines-search/widgets/source_item_widget.dart +++ b/lib/headlines-search/widgets/source_item_widget.dart @@ -14,9 +14,9 @@ class SourceItemWidget extends StatelessWidget { Widget build(BuildContext context) { return ListTile( title: Text(source.name), - subtitle: source.description != null + subtitle: source.description.isNotEmpty ? Text( - source.description!, + source.description, maxLines: 2, overflow: TextOverflow.ellipsis, ) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3b347089..51642ad0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -98,12 +98,6 @@ abstract class AppLocalizations { Locale('en'), ]; - /// Text shown in the AppBar of the Counter Page - /// - /// In en, this message translates to: - /// **'Counter'** - String get counterAppBarTitle; - /// Title for the account linking page /// /// In en, this message translates to: @@ -1064,6 +1058,114 @@ abstract class AppLocalizations { /// **'Unfollow {categoryName}'** String unfollowCategoryTooltip(String categoryName); + /// Title for the page where users can add topics to follow + /// + /// In en, this message translates to: + /// **'Follow Topics'** + String get addTopicsPageTitle; + + /// Headline for loading state on topic filter page + /// + /// In en, this message translates to: + /// **'Loading Topics...'** + String get topicFilterLoadingHeadline; + + /// Error message when topics fail to load on the filter/add page + /// + /// In en, this message translates to: + /// **'Could not load topics. Please try again.'** + String get topicFilterError; + + /// Headline for empty state on topic filter page + /// + /// In en, this message translates to: + /// **'No Topics Found'** + String get topicFilterEmptyHeadline; + + /// Subheadline for empty state on topic filter page + /// + /// In en, this message translates to: + /// **'There are no topics available at the moment.'** + String get topicFilterEmptySubheadline; + + /// Tooltip for the button to unfollow a specific topic + /// + /// In en, this message translates to: + /// **'Unfollow {topicName}'** + String unfollowTopicTooltip(String topicName); + + /// Tooltip for the button to follow a specific topic + /// + /// In en, this message translates to: + /// **'Follow {topicName}'** + String followTopicTooltip(String topicName); + + /// Headline for loading state on followed sources page + /// + /// In en, this message translates to: + /// **'Loading Followed Sources...'** + String get followedSourcesLoadingHeadline; + + /// Error message when followed sources fail to load + /// + /// In en, this message translates to: + /// **'Could Not Load Followed Sources'** + String get followedSourcesErrorHeadline; + + /// Headline for empty state on followed sources page + /// + /// In en, this message translates to: + /// **'No Followed Sources'** + String get followedSourcesEmptyHeadline; + + /// Subheadline for empty state on followed sources page + /// + /// In en, this message translates to: + /// **'Start following sources to see them here.'** + String get followedSourcesEmptySubheadline; + + /// Label for the topic filter dropdown + /// + /// In en, this message translates to: + /// **'Topic'** + String get headlinesFeedFilterTopicLabel; + + /// Title for the page listing followed topics + /// + /// In en, this message translates to: + /// **'Followed Topics'** + String get followedTopicsPageTitle; + + /// Tooltip for the button to add new topics to follow + /// + /// In en, this message translates to: + /// **'Add Topics'** + String get addTopicsTooltip; + + /// Headline for loading state on followed topics page + /// + /// In en, this message translates to: + /// **'Loading Followed Topics...'** + String get followedTopicsLoadingHeadline; + + /// Error message when followed topics fail to load + /// + /// In en, this message translates to: + /// **'Could Not Load Followed Topics'** + String get followedTopicsErrorHeadline; + + /// Headline for empty state on followed topics page + /// + /// In en, this message translates to: + /// **'No Followed Topics'** + String get followedTopicsEmptyHeadline; + + /// Subheadline for empty state on followed topics page + /// + /// In en, this message translates to: + /// **'Start following topics to see them here.'** + String get followedTopicsEmptySubheadline; + /// Title for the page listing followed sources /// /// In en, this message translates to: @@ -1322,6 +1424,12 @@ abstract class AppLocalizations { /// **'Source'** String get entityDetailsSourceTitle; + /// Title for topic entity type + /// + /// In en, this message translates to: + /// **'Topic'** + String get entityDetailsTopicTitle; + /// Title for country entity type /// /// In en, this message translates to: @@ -1387,6 +1495,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Demo Mode: Use code {code}'** String demoVerificationCodeMessage(String code); + + /// Label for Headline content type + /// + /// In en, this message translates to: + /// **'Headlines'** + String get contentTypeHeadline; + + /// Label for Topic content type + /// + /// In en, this message translates to: + /// **'Topics'** + String get contentTypeTopic; + + /// Label for Source content type + /// + /// In en, this message translates to: + /// **'Sources'** + String get contentTypeSource; + + /// Label for Country content type + /// + /// In en, this message translates to: + /// **'Countries'** + String get contentTypeCountry; + + /// Subheadline for loading state on search page + /// + /// In en, this message translates to: + /// **'Searching for {contentType}...'** + String searchingFor(String contentType); + + /// Label for the light font weight option + /// + /// In en, this message translates to: + /// **'Light'** + String get settingsAppearanceFontWeightLight; + + /// Label for the regular font weight option + /// + /// In en, this message translates to: + /// **'Regular'** + String get settingsAppearanceFontWeightRegular; + + /// Label for the bold font weight option + /// + /// In en, this message translates to: + /// **'Bold'** + String get settingsAppearanceFontWeightBold; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index f7542e7a..51110ef0 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -8,9 +8,6 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); - @override - String get counterAppBarTitle => '[AR] Counter'; - @override String get accountLinkingPageTitle => 'ربط حسابك'; @@ -524,6 +521,69 @@ class AppLocalizationsAr extends AppLocalizations { return 'إلغاء متابعة $categoryName'; } + @override + String get addTopicsPageTitle => 'متابعة المواضيع'; + + @override + String get topicFilterLoadingHeadline => 'جارٍ تحميل المواضيع...'; + + @override + String get topicFilterError => 'تعذر تحميل المواضيع. يرجى المحاولة مرة أخرى.'; + + @override + String get topicFilterEmptyHeadline => 'لم يتم العثور على مواضيع'; + + @override + String get topicFilterEmptySubheadline => + 'لا توجد مواضيع متاحة في الوقت الحالي.'; + + @override + String unfollowTopicTooltip(String topicName) { + return 'إلغاء متابعة $topicName'; + } + + @override + String followTopicTooltip(String topicName) { + return 'متابعة $topicName'; + } + + @override + String get followedSourcesLoadingHeadline => + 'جارٍ تحميل المصادر المتابَعة...'; + + @override + String get followedSourcesErrorHeadline => 'تعذر تحميل المصادر المتابَعة'; + + @override + String get followedSourcesEmptyHeadline => 'لا توجد مصادر متابَعة'; + + @override + String get followedSourcesEmptySubheadline => + 'ابدأ بمتابعة المصادر لكي تظهر هنا.'; + + @override + String get headlinesFeedFilterTopicLabel => 'الموضوع'; + + @override + String get followedTopicsPageTitle => 'المواضيع المتابَعة'; + + @override + String get addTopicsTooltip => 'إضافة مواضيع'; + + @override + String get followedTopicsLoadingHeadline => + 'جارٍ تحميل المواضيع المتابَعة...'; + + @override + String get followedTopicsErrorHeadline => 'تعذر تحميل المواضيع المتابَعة'; + + @override + String get followedTopicsEmptyHeadline => 'لا توجد مواضيع متابَعة'; + + @override + String get followedTopicsEmptySubheadline => + 'ابدأ بمتابعة المواضيع لكي تظهر هنا.'; + @override String get followedSourcesPageTitle => 'المصادر المتابَعة'; @@ -671,6 +731,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get entityDetailsSourceTitle => 'المصدر'; + @override + String get entityDetailsTopicTitle => 'الموضوع'; + @override String get entityDetailsCountryTitle => 'الدولة'; @@ -710,4 +773,30 @@ class AppLocalizationsAr extends AppLocalizations { String demoVerificationCodeMessage(String code) { return 'وضع العرض التوضيحي: استخدم الرمز $code'; } + + @override + String get contentTypeHeadline => 'العناوين الرئيسية'; + + @override + String get contentTypeTopic => 'المواضيع'; + + @override + String get contentTypeSource => 'المصادر'; + + @override + String get contentTypeCountry => 'الدول'; + + @override + String searchingFor(String contentType) { + return 'جار البحث عن $contentType...'; + } + + @override + String get settingsAppearanceFontWeightLight => 'صغير'; + + @override + String get settingsAppearanceFontWeightRegular => 'عادي'; + + @override + String get settingsAppearanceFontWeightBold => 'عريض'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5da677d7..d35f7108 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -8,9 +8,6 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); - @override - String get counterAppBarTitle => 'Counter'; - @override String get accountLinkingPageTitle => 'Link Your Account'; @@ -525,6 +522,67 @@ class AppLocalizationsEn extends AppLocalizations { return 'Unfollow $categoryName'; } + @override + String get addTopicsPageTitle => 'Follow Topics'; + + @override + String get topicFilterLoadingHeadline => 'Loading Topics...'; + + @override + String get topicFilterError => 'Could not load topics. Please try again.'; + + @override + String get topicFilterEmptyHeadline => 'No Topics Found'; + + @override + String get topicFilterEmptySubheadline => + 'There are no topics available at the moment.'; + + @override + String unfollowTopicTooltip(String topicName) { + return 'Unfollow $topicName'; + } + + @override + String followTopicTooltip(String topicName) { + return 'Follow $topicName'; + } + + @override + String get followedSourcesLoadingHeadline => 'Loading Followed Sources...'; + + @override + String get followedSourcesErrorHeadline => 'Could Not Load Followed Sources'; + + @override + String get followedSourcesEmptyHeadline => 'No Followed Sources'; + + @override + String get followedSourcesEmptySubheadline => + 'Start following sources to see them here.'; + + @override + String get headlinesFeedFilterTopicLabel => 'Topic'; + + @override + String get followedTopicsPageTitle => 'Followed Topics'; + + @override + String get addTopicsTooltip => 'Add Topics'; + + @override + String get followedTopicsLoadingHeadline => 'Loading Followed Topics...'; + + @override + String get followedTopicsErrorHeadline => 'Could Not Load Followed Topics'; + + @override + String get followedTopicsEmptyHeadline => 'No Followed Topics'; + + @override + String get followedTopicsEmptySubheadline => + 'Start following topics to see them here.'; + @override String get followedSourcesPageTitle => 'Followed Sources'; @@ -673,6 +731,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get entityDetailsSourceTitle => 'Source'; + @override + String get entityDetailsTopicTitle => 'Topic'; + @override String get entityDetailsCountryTitle => 'Country'; @@ -712,4 +773,30 @@ class AppLocalizationsEn extends AppLocalizations { String demoVerificationCodeMessage(String code) { return 'Demo Mode: Use code $code'; } + + @override + String get contentTypeHeadline => 'Headlines'; + + @override + String get contentTypeTopic => 'Topics'; + + @override + String get contentTypeSource => 'Sources'; + + @override + String get contentTypeCountry => 'Countries'; + + @override + String searchingFor(String contentType) { + return 'Searching for $contentType...'; + } + + @override + String get settingsAppearanceFontWeightLight => 'Light'; + + @override + String get settingsAppearanceFontWeightRegular => 'Regular'; + + @override + String get settingsAppearanceFontWeightBold => 'Bold'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 719d3e51..be634914 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1,9 +1,5 @@ { "@@locale": "ar", - "counterAppBarTitle": "[AR] Counter", - "@counterAppBarTitle": { - "description": "Text shown in the AppBar of the Counter Page" - }, "accountLinkingPageTitle": "ربط حسابك", "@accountLinkingPageTitle": { "description": "Title for the account linking page" @@ -602,7 +598,6 @@ "@headlinesFeedFilterNoSourcesMatch": { "description": "Message shown when no sources match the selected filters" }, - "searchModelTypeHeadline": "العناوين الرئيسية", "@searchModelTypeHeadline": { "description": "Dropdown display name for Headline search type" @@ -635,7 +630,6 @@ "@searchHintTextCountry": { "description": "Hint text for searching countries" }, - "searchPageInitialHeadline": "ابدأ بحثك", "@searchPageInitialHeadline": { "description": "Generic headline for the initial state of the search page" @@ -670,6 +664,90 @@ } } }, + "addTopicsPageTitle": "متابعة المواضيع", + "@addTopicsPageTitle": { + "description": "عنوان الصفحة التي يمكن للمستخدمين من خلالها إضافة مواضيع لمتابعتها" + }, + "topicFilterLoadingHeadline": "جارٍ تحميل المواضيع...", + "@topicFilterLoadingHeadline": { + "description": "عنوان لحالة التحميل في صفحة تصفية المواضيع" + }, + "topicFilterError": "تعذر تحميل المواضيع. يرجى المحاولة مرة أخرى.", + "@topicFilterError": { + "description": "رسالة خطأ عند فشل تحميل المواضيع في صفحة التصفية/الإضافة" + }, + "topicFilterEmptyHeadline": "لم يتم العثور على مواضيع", + "@topicFilterEmptyHeadline": { + "description": "عنوان للحالة الفارغة في صفحة تصفية المواضيع" + }, + "topicFilterEmptySubheadline": "لا توجد مواضيع متاحة في الوقت الحالي.", + "@topicFilterEmptySubheadline": { + "description": "عنوان فرعي للحالة الفارغة في صفحة تصفية المواضيع" + }, + "unfollowTopicTooltip": "إلغاء متابعة {topicName}", + "@unfollowTopicTooltip": { + "description": "تلميح زر إلغاء متابعة موضوع معين", + "placeholders": { + "topicName": { + "type": "String", + "example": "علوم" + } + } + }, + "followTopicTooltip": "متابعة {topicName}", + "@followTopicTooltip": { + "description": "تلميح زر متابعة موضوع معين", + "placeholders": { + "topicName": { + "type": "String", + "example": "علوم" + } + } + }, + "followedSourcesLoadingHeadline": "جارٍ تحميل المصادر المتابَعة...", + "@followedSourcesLoadingHeadline": { + "description": "عنوان لحالة التحميل في صفحة المصادر المتابعة" + }, + "followedSourcesErrorHeadline": "تعذر تحميل المصادر المتابَعة", + "@followedSourcesErrorHeadline": { + "description": "رسالة خطأ عند فشل تحميل المصادر المتابعة" + }, + "followedSourcesEmptyHeadline": "لا توجد مصادر متابَعة", + "@followedSourcesEmptyHeadline": { + "description": "عنوان للحالة الفارغة في صفحة المصادر المتابعة" + }, + "followedSourcesEmptySubheadline": "ابدأ بمتابعة المصادر لكي تظهر هنا.", + "@followedSourcesEmptySubheadline": { + "description": "عنوان فرعي للحالة الفارغة في صفحة المصادر المتابعة" + }, + "headlinesFeedFilterTopicLabel": "الموضوع", + "@headlinesFeedFilterTopicLabel": { + "description": "Label for the topic filter dropdown" + }, + "followedTopicsPageTitle": "المواضيع المتابَعة", + "@followedTopicsPageTitle": { + "description": "عنوان صفحة عرض المواضيع المتابعة" + }, + "addTopicsTooltip": "إضافة مواضيع", + "@addTopicsTooltip": { + "description": "تلميح زر إضافة مواضيع جديدة للمتابعة" + }, + "followedTopicsLoadingHeadline": "جارٍ تحميل المواضيع المتابَعة...", + "@followedTopicsLoadingHeadline": { + "description": "عنوان لحالة التحميل في صفحة المواضيع المتابعة" + }, + "followedTopicsErrorHeadline": "تعذر تحميل المواضيع المتابَعة", + "@followedTopicsErrorHeadline": { + "description": "رسالة خطأ عند فشل تحميل المواضيع المتابعة" + }, + "followedTopicsEmptyHeadline": "لا توجد مواضيع متابَعة", + "@followedTopicsEmptyHeadline": { + "description": "عنوان للحالة الفارغة في صفحة المواضيع المتابعة" + }, + "followedTopicsEmptySubheadline": "ابدأ بمتابعة المواضيع لكي تظهر هنا.", + "@followedTopicsEmptySubheadline": { + "description": "عنوان فرعي للحالة الفارغة في صفحة المواضيع المتابعة" + }, "followedSourcesPageTitle": "المصادر المتابَعة", "@followedSourcesPageTitle": { "description": "عنوان صفحة عرض المصادر المتابعة" @@ -872,6 +950,10 @@ "@entityDetailsSourceTitle": { "description": "Title for source entity type" }, + "entityDetailsTopicTitle": "الموضوع", + "@entityDetailsTopicTitle": { + "description": "Title for topic entity type" + }, "entityDetailsCountryTitle": "الدولة", "@entityDetailsCountryTitle": { "description": "Title for country entity type" @@ -896,7 +978,7 @@ "@savedHeadlinesEmptySubheadline": { "description": "Subheadline for empty state on saved headlines page" }, - "demoVerificationCodeMessage": "وضع العرض التوضيحي: استخدم الرمز {code}", + "demoVerificationCodeMessage": "وضع العرض التوضيحي: استخدم الرمز {code}", "@demoVerificationCodeMessage": { "description": "رسالة تظهر في وضع العرض التوضيحي لتوفير رمز التحقق", "placeholders": { @@ -905,5 +987,42 @@ "example": "123456" } } + }, + "contentTypeHeadline": "العناوين الرئيسية", + "@contentTypeHeadline": { + "description": "Label for Headline content type" + }, + "contentTypeTopic": "المواضيع", + "@contentTypeTopic": { + "description": "Label for Topic content type" + }, + "contentTypeSource": "المصادر", + "@contentTypeSource": { + "description": "Label for Source content type" + }, + "contentTypeCountry": "الدول", + "@contentTypeCountry": { + "description": "Label for Country content type" + }, + "searchingFor": "جار البحث عن {contentType}...", + "@searchingFor": { + "description": "Subheadline for loading state on search page", + "placeholders": { + "contentType": { + "type": "String" + } + } + }, + "settingsAppearanceFontWeightLight": "صغير", + "@settingsAppearanceFontWeightLight": { + "description": "Label for the light font weight option" + }, + "settingsAppearanceFontWeightRegular": "عادي", + "@settingsAppearanceFontWeightRegular": { + "description": "Label for the regular font weight option" + }, + "settingsAppearanceFontWeightBold": "عريض", + "@settingsAppearanceFontWeightBold": { + "description": "Label for the bold font weight option" } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 9b21287c..dce3c4dc 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,9 +1,5 @@ { "@@locale": "en", - "counterAppBarTitle": "Counter", - "@counterAppBarTitle": { - "description": "Text shown in the AppBar of the Counter Page" - }, "accountLinkingPageTitle": "Link Your Account", "@accountLinkingPageTitle": { "description": "Title for the account linking page" @@ -670,6 +666,90 @@ } } }, + "addTopicsPageTitle": "Follow Topics", + "@addTopicsPageTitle": { + "description": "Title for the page where users can add topics to follow" + }, + "topicFilterLoadingHeadline": "Loading Topics...", + "@topicFilterLoadingHeadline": { + "description": "Headline for loading state on topic filter page" + }, + "topicFilterError": "Could not load topics. Please try again.", + "@topicFilterError": { + "description": "Error message when topics fail to load on the filter/add page" + }, + "topicFilterEmptyHeadline": "No Topics Found", + "@topicFilterEmptyHeadline": { + "description": "Headline for empty state on topic filter page" + }, + "topicFilterEmptySubheadline": "There are no topics available at the moment.", + "@topicFilterEmptySubheadline": { + "description": "Subheadline for empty state on topic filter page" + }, + "unfollowTopicTooltip": "Unfollow {topicName}", + "@unfollowTopicTooltip": { + "description": "Tooltip for the button to unfollow a specific topic", + "placeholders": { + "topicName": { + "type": "String", + "example": "Science" + } + } + }, + "followTopicTooltip": "Follow {topicName}", + "@followTopicTooltip": { + "description": "Tooltip for the button to follow a specific topic", + "placeholders": { + "topicName": { + "type": "String", + "example": "Science" + } + } + }, + "followedSourcesLoadingHeadline": "Loading Followed Sources...", + "@followedSourcesLoadingHeadline": { + "description": "Headline for loading state on followed sources page" + }, + "followedSourcesErrorHeadline": "Could Not Load Followed Sources", + "@followedSourcesErrorHeadline": { + "description": "Error message when followed sources fail to load" + }, + "followedSourcesEmptyHeadline": "No Followed Sources", + "@followedSourcesEmptyHeadline": { + "description": "Headline for empty state on followed sources page" + }, + "followedSourcesEmptySubheadline": "Start following sources to see them here.", + "@followedSourcesEmptySubheadline": { + "description": "Subheadline for empty state on followed sources page" + }, + "headlinesFeedFilterTopicLabel": "Topic", + "@headlinesFeedFilterTopicLabel": { + "description": "Label for the topic filter dropdown" + }, + "followedTopicsPageTitle": "Followed Topics", + "@followedTopicsPageTitle": { + "description": "Title for the page listing followed topics" + }, + "addTopicsTooltip": "Add Topics", + "@addTopicsTooltip": { + "description": "Tooltip for the button to add new topics to follow" + }, + "followedTopicsLoadingHeadline": "Loading Followed Topics...", + "@followedTopicsLoadingHeadline": { + "description": "Headline for loading state on followed topics page" + }, + "followedTopicsErrorHeadline": "Could Not Load Followed Topics", + "@followedTopicsErrorHeadline": { + "description": "Error message when followed topics fail to load" + }, + "followedTopicsEmptyHeadline": "No Followed Topics", + "@followedTopicsEmptyHeadline": { + "description": "Headline for empty state on followed topics page" + }, + "followedTopicsEmptySubheadline": "Start following topics to see them here.", + "@followedTopicsEmptySubheadline": { + "description": "Subheadline for empty state on followed topics page" + }, "followedSourcesPageTitle": "Followed Sources", "@followedSourcesPageTitle": { "description": "Title for the page listing followed sources" @@ -872,6 +952,10 @@ "@entityDetailsSourceTitle": { "description": "Title for source entity type" }, + "entityDetailsTopicTitle": "Topic", + "@entityDetailsTopicTitle": { + "description": "Title for topic entity type" + }, "entityDetailsCountryTitle": "Country", "@entityDetailsCountryTitle": { "description": "Title for country entity type" @@ -921,5 +1005,42 @@ "example": "123456" } } + }, + "contentTypeHeadline": "Headlines", + "@contentTypeHeadline": { + "description": "Label for Headline content type" + }, + "contentTypeTopic": "Topics", + "@contentTypeTopic": { + "description": "Label for Topic content type" + }, + "contentTypeSource": "Sources", + "@contentTypeSource": { + "description": "Label for Source content type" + }, + "contentTypeCountry": "Countries", + "@contentTypeCountry": { + "description": "Label for Country content type" + }, + "searchingFor": "Searching for {contentType}...", + "@searchingFor": { + "description": "Subheadline for loading state on search page", + "placeholders": { + "contentType": { + "type": "String" + } + } + }, + "settingsAppearanceFontWeightLight": "Light", + "@settingsAppearanceFontWeightLight": { + "description": "Label for the light font weight option" + }, + "settingsAppearanceFontWeightRegular": "Regular", + "@settingsAppearanceFontWeightRegular": { + "description": "Label for the regular font weight option" + }, + "settingsAppearanceFontWeightBold": "Bold", + "@settingsAppearanceFontWeightBold": { + "description": "Label for the bold font weight option" } } diff --git a/lib/router/router.dart b/lib/router/router.dart index aa8aae63..45a5b6c2 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -5,13 +5,11 @@ import 'package:ht_auth_repository/ht_auth_repository.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_main/account/bloc/account_bloc.dart'; import 'package:ht_main/account/view/account_page.dart'; -import 'package:ht_main/account/view/manage_followed_items/categories/add_category_to_follow_page.dart'; -import 'package:ht_main/account/view/manage_followed_items/categories/followed_categories_list_page.dart'; -// import 'package:ht_main/account/view/manage_followed_items/countries/add_country_to_follow_page.dart'; -// import 'package:ht_main/account/view/manage_followed_items/countries/followed_countries_list_page.dart'; import 'package:ht_main/account/view/manage_followed_items/manage_followed_items_page.dart'; import 'package:ht_main/account/view/manage_followed_items/sources/add_source_to_follow_page.dart'; import 'package:ht_main/account/view/manage_followed_items/sources/followed_sources_list_page.dart'; +import 'package:ht_main/account/view/manage_followed_items/topics/add_topic_to_follow_page.dart'; +import 'package:ht_main/account/view/manage_followed_items/topics/followed_topics_list_page.dart'; import 'package:ht_main/account/view/saved_headlines_page.dart'; import 'package:ht_main/app/bloc/app_bloc.dart'; import 'package:ht_main/app/config/config.dart' as local_config; @@ -24,15 +22,15 @@ import 'package:ht_main/entity_details/view/entity_details_page.dart'; import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart'; import 'package:ht_main/headline-details/bloc/similar_headlines_bloc.dart'; import 'package:ht_main/headline-details/view/headline_details_page.dart'; -import 'package:ht_main/headlines-feed/bloc/categories_filter_bloc.dart'; // import 'package:ht_main/headlines-feed/bloc/countries_filter_bloc.dart'; import 'package:ht_main/headlines-feed/bloc/headlines_feed_bloc.dart'; import 'package:ht_main/headlines-feed/bloc/sources_filter_bloc.dart'; -import 'package:ht_main/headlines-feed/view/category_filter_page.dart'; +import 'package:ht_main/headlines-feed/bloc/topics_filter_bloc.dart'; // import 'package:ht_main/headlines-feed/view/country_filter_page.dart'; import 'package:ht_main/headlines-feed/view/headlines_feed_page.dart'; import 'package:ht_main/headlines-feed/view/headlines_filter_page.dart'; import 'package:ht_main/headlines-feed/view/source_filter_page.dart'; +import 'package:ht_main/headlines-feed/view/topic_filter_page.dart'; import 'package:ht_main/headlines-search/bloc/headlines_search_bloc.dart'; import 'package:ht_main/headlines-search/view/headlines_search_page.dart'; import 'package:ht_main/l10n/l10n.dart'; @@ -46,7 +44,7 @@ import 'package:ht_main/settings/view/notification_settings_page.dart'; import 'package:ht_main/settings/view/settings_page.dart'; import 'package:ht_main/settings/view/theme_settings_page.dart'; import 'package:ht_main/shared/services/feed_injector_service.dart'; -import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_shared/ht_shared.dart' hide AppStatus; /// Creates and configures the GoRouter instance for the application. /// @@ -56,13 +54,13 @@ GoRouter createRouter({ required ValueNotifier authStatusNotifier, required HtAuthRepository htAuthenticationRepository, required HtDataRepository htHeadlinesRepository, - required HtDataRepository htCategoriesRepository, + required HtDataRepository htTopicsRepository, required HtDataRepository htCountriesRepository, required HtDataRepository htSourcesRepository, required HtDataRepository htUserAppSettingsRepository, required HtDataRepository htUserContentPreferencesRepository, - required HtDataRepository htAppConfigRepository, + required HtDataRepository htRemoteConfigRepository, required local_config.AppEnvironment environment, }) { // Instantiate AccountBloc once to be shared @@ -79,7 +77,7 @@ GoRouter createRouter({ // --- Redirect Logic --- redirect: (BuildContext context, GoRouterState state) { final appStatus = context.read().state.status; - final appConfig = context.read().state.appConfig; + final appConfig = context.read().state.remoteConfig; final currentLocation = state.matchedLocation; final currentUri = state.uri; @@ -280,14 +278,14 @@ GoRouter createRouter({ ), // --- Entity Details Routes (Top Level) --- GoRoute( - path: Routes.categoryDetails, - name: Routes.categoryDetailsName, + path: Routes.topicDetails, + name: Routes.topicDetailsName, builder: (context, state) { final args = state.extra as EntityDetailsPageArguments?; if (args == null) { return const Scaffold( body: Center( - child: Text('Error: Missing category details arguments'), + child: Text('Error: Missing topic details arguments'), ), ); } @@ -400,8 +398,7 @@ GoRouter createRouter({ return HeadlinesSearchBloc( headlinesRepository: context .read>(), - categoryRepository: context - .read>(), + topicRepository: context.read>(), sourceRepository: context.read>(), appBloc: context.read(), feedInjectorService: feedInjectorService, @@ -481,17 +478,17 @@ GoRouter createRouter({ ); }, routes: [ - // Sub-route for category selection + // Sub-route for topic selection GoRoute( - path: Routes.feedFilterCategories, - name: Routes.feedFilterCategoriesName, + path: Routes.feedFilterTopics, + name: Routes.feedFilterTopicsName, // Wrap with BlocProvider builder: (context, state) => BlocProvider( - create: (context) => CategoriesFilterBloc( - categoriesRepository: context - .read>(), + create: (context) => TopicsFilterBloc( + topicsRepository: context + .read>(), ), - child: const CategoryFilterPage(), + child: const TopicFilterPage(), ), ), // Sub-route for source selection @@ -683,16 +680,16 @@ GoRouter createRouter({ const ManageFollowedItemsPage(), routes: [ GoRoute( - path: Routes.followedCategoriesList, - name: Routes.followedCategoriesListName, + path: Routes.followedTopicsList, + name: Routes.followedTopicsListName, builder: (context, state) => - const FollowedCategoriesListPage(), + const FollowedTopicsListPage(), routes: [ GoRoute( - path: Routes.addCategoryToFollow, - name: Routes.addCategoryToFollowName, + path: Routes.addTopicToFollow, + name: Routes.addTopicToFollowName, builder: (context, state) => - const AddCategoryToFollowPage(), + const AddTopicToFollowPage(), ), ], ), @@ -761,9 +758,3 @@ GoRouter createRouter({ ], ); } - -// Placeholder pages were moved to their respective files: -// - lib/headlines-feed/view/headlines_filter_page.dart -// - lib/headlines-feed/view/category_filter_page.dart -// - lib/headlines-feed/view/source_filter_page.dart -// - lib/headlines-feed/view/country_filter_page.dart diff --git a/lib/router/routes.dart b/lib/router/routes.dart index 10ae94ac..087d2088 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -10,8 +10,8 @@ abstract final class Routes { static const feedFilter = 'filter'; static const feedFilterName = 'feedFilter'; - static const feedFilterCategories = 'categories'; - static const feedFilterCategoriesName = 'feedFilterCategories'; + static const feedFilterTopics = 'topics'; + static const feedFilterTopicsName = 'feedFilterTopics'; static const feedFilterSources = 'sources'; static const feedFilterSourcesName = 'feedFilterSources'; @@ -34,8 +34,8 @@ abstract final class Routes { static const notificationsName = 'notifications'; // --- Entity Details Routes (can be accessed from multiple places) --- - static const categoryDetails = '/category-details'; - static const categoryDetailsName = 'categoryDetails'; + static const topicDetails = '/topic-details'; + static const topicDetailsName = 'topicDetails'; static const sourceDetails = '/source-details'; static const sourceDetailsName = 'sourceDetails'; @@ -104,10 +104,10 @@ abstract final class Routes { static const globalArticleDetailsName = 'globalArticleDetails'; // --- Manage Followed Items Sub-Routes (relative to /account/manage-followed-items) --- - static const followedCategoriesList = 'categories'; - static const followedCategoriesListName = 'followedCategoriesList'; - static const addCategoryToFollow = 'add-category'; - static const addCategoryToFollowName = 'addCategoryToFollow'; + static const followedTopicsList = 'topics'; + static const followedTopicsListName = 'followedTopicsList'; + static const addTopicToFollow = 'add-topic'; + static const addTopicToFollowName = 'addTopicToFollow'; static const followedSourcesList = 'sources'; static const followedSourcesListName = 'followedSourcesList'; diff --git a/lib/settings/bloc/settings_bloc.dart b/lib/settings/bloc/settings_bloc.dart index 1d2e6b6f..5d295e90 100644 --- a/lib/settings/bloc/settings_bloc.dart +++ b/lib/settings/bloc/settings_bloc.dart @@ -44,8 +44,8 @@ class SettingsBloc extends Bloc { _onAppFontWeightChanged, transformer: sequential(), ); - on( - _onFeedTileTypeChanged, + on( + _onHeadlineImageStyleChanged, transformer: sequential(), ); on(_onLanguageChanged, transformer: sequential()); @@ -99,7 +99,23 @@ class SettingsBloc extends Bloc { ); } on NotFoundException { // Settings not found for the user, create and persist defaults - final defaultSettings = UserAppSettings(id: event.userId); + final defaultSettings = UserAppSettings( + id: event.userId, + displaySettings: const DisplaySettings( + baseTheme: AppBaseTheme.system, + accentTheme: AppAccentTheme.defaultBlue, + fontFamily: 'SystemDefault', + textScaleFactor: AppTextScaleFactor.medium, + fontWeight: AppFontWeight.regular, + ), + language: 'en', + feedPreferences: const FeedDisplayPreferences( + headlineDensity: HeadlineDensity.standard, + headlineImageStyle: HeadlineImageStyle.largeThumbnail, + showSourceInHeadlineFeed: true, + showPublishDateInHeadlineFeed: true, + ), + ); emit( state.copyWith( status: SettingsStatus.success, @@ -165,18 +181,12 @@ class SettingsBloc extends Bloc { Emitter emit, ) async { if (state.userAppSettings == null) return; - print( - '[SettingsBloc] _onAppFontTypeChanged: Received event.fontType: ${event.fontType}', - ); final updatedSettings = state.userAppSettings!.copyWith( displaySettings: state.userAppSettings!.displaySettings.copyWith( fontFamily: event.fontType, ), ); - print( - '[SettingsBloc] _onAppFontTypeChanged: Updated settings.fontFamily: ${updatedSettings.displaySettings.fontFamily}', - ); emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); await _persistSettings(updatedSettings, emit); } @@ -186,31 +196,25 @@ class SettingsBloc extends Bloc { Emitter emit, ) async { if (state.userAppSettings == null) return; - print( - '[SettingsBloc] _onAppFontWeightChanged: Received event.fontWeight: ${event.fontWeight}', - ); final updatedSettings = state.userAppSettings!.copyWith( displaySettings: state.userAppSettings!.displaySettings.copyWith( fontWeight: event.fontWeight, ), ); - print( - '[SettingsBloc] _onAppFontWeightChanged: Updated settings.fontWeight: ${updatedSettings.displaySettings.fontWeight}', - ); emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); await _persistSettings(updatedSettings, emit); } - Future _onFeedTileTypeChanged( - SettingsFeedTileTypeChanged event, + Future _onHeadlineImageStyleChanged( + SettingsHeadlineImageStyleChanged event, Emitter emit, ) async { if (state.userAppSettings == null) return; final updatedSettings = state.userAppSettings!.copyWith( feedPreferences: state.userAppSettings!.feedPreferences.copyWith( - headlineImageStyle: event.tileType, + headlineImageStyle: event.imageStyle, ), ); emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); diff --git a/lib/settings/bloc/settings_event.dart b/lib/settings/bloc/settings_event.dart index f0716610..3629565e 100644 --- a/lib/settings/bloc/settings_event.dart +++ b/lib/settings/bloc/settings_event.dart @@ -104,19 +104,18 @@ class SettingsAppFontWeightChanged extends SettingsEvent { // --- Feed Settings Events --- -/// {@template settings_feed_tile_type_changed} -/// Event added when the user changes the feed list tile type. +/// {@template settings_headline_image_style_changed} +/// Event added when the user changes the headline image style in the feed. /// {@endtemplate} -class SettingsFeedTileTypeChanged extends SettingsEvent { - /// {@macro settings_feed_tile_type_changed} - const SettingsFeedTileTypeChanged(this.tileType); +class SettingsHeadlineImageStyleChanged extends SettingsEvent { + /// {@macro settings_headline_image_style_changed} + const SettingsHeadlineImageStyleChanged(this.imageStyle); - /// The newly selected feed list tile type. - // Note: This event might need to be split into density and image style changes. - final HeadlineImageStyle tileType; + /// The newly selected headline image style. + final HeadlineImageStyle imageStyle; @override - List get props => [tileType]; + List get props => [imageStyle]; } /// {@template settings_language_changed} diff --git a/lib/settings/view/appearance_settings_page.dart b/lib/settings/view/appearance_settings_page.dart index b3ab3f51..7dcb165e 100644 --- a/lib/settings/view/appearance_settings_page.dart +++ b/lib/settings/view/appearance_settings_page.dart @@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; import 'package:ht_main/settings/bloc/settings_bloc.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template appearance_settings_page} /// A menu page for navigating to theme and font appearance settings. @@ -15,7 +15,7 @@ class AppearanceSettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; // SettingsBloc is watched to ensure settings are loaded, // though this page itself doesn't dispatch events. final settingsState = context.watch().state; diff --git a/lib/settings/view/feed_settings_page.dart b/lib/settings/view/feed_settings_page.dart index d103da89..eb81d33c 100644 --- a/lib/settings/view/feed_settings_page.dart +++ b/lib/settings/view/feed_settings_page.dart @@ -4,8 +4,8 @@ import 'package:ht_main/app/bloc/app_bloc.dart'; import 'package:ht_main/l10n/app_localizations.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/settings/bloc/settings_bloc.dart'; -import 'package:ht_main/shared/constants/constants.dart'; import 'package:ht_shared/ht_shared.dart' show HeadlineImageStyle; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template feed_settings_page} /// A page for configuring feed display settings. @@ -28,7 +28,7 @@ class FeedSettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final settingsBloc = context.watch(); final state = settingsBloc.state; @@ -61,7 +61,7 @@ class FeedSettingsPage extends StatelessWidget { itemToString: (style) => _imageStyleToString(style, l10n), onChanged: (value) { if (value != null) { - settingsBloc.add(SettingsFeedTileTypeChanged(value)); + settingsBloc.add(SettingsHeadlineImageStyleChanged(value)); } }, ), diff --git a/lib/settings/view/font_settings_page.dart b/lib/settings/view/font_settings_page.dart index 7f807963..5f49b529 100644 --- a/lib/settings/view/font_settings_page.dart +++ b/lib/settings/view/font_settings_page.dart @@ -4,9 +4,9 @@ import 'package:ht_main/app/bloc/app_bloc.dart'; import 'package:ht_main/l10n/app_localizations.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/settings/bloc/settings_bloc.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; import 'package:ht_shared/ht_shared.dart' show AppFontWeight, AppTextScaleFactor; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template font_settings_page} /// A page for configuring font-related settings like size, family, and weight. @@ -41,20 +41,19 @@ class FontSettingsPage extends StatelessWidget { // Helper to map AppFontWeight enum to user-friendly strings String _fontWeightToString(AppFontWeight weight, AppLocalizations l10n) { - // Using direct strings as placeholders until specific l10n keys are confirmed switch (weight) { case AppFontWeight.light: - return 'Light'; + return l10n.settingsAppearanceFontWeightLight; case AppFontWeight.regular: - return 'Regular'; + return l10n.settingsAppearanceFontWeightRegular; case AppFontWeight.bold: - return 'Bold'; + return l10n.settingsAppearanceFontWeightBold; } } @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final settingsBloc = context.watch(); final state = settingsBloc.state; diff --git a/lib/settings/view/language_settings_page.dart b/lib/settings/view/language_settings_page.dart index 8f1e5099..467a599d 100644 --- a/lib/settings/view/language_settings_page.dart +++ b/lib/settings/view/language_settings_page.dart @@ -3,7 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:ht_main/app/bloc/app_bloc.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/settings/bloc/settings_bloc.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; // Defines the available languages and their display names. // In a real app, this might come from a configuration or be more dynamic. @@ -21,7 +21,7 @@ class LanguageSettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final settingsBloc = context.watch(); final settingsState = settingsBloc.state; diff --git a/lib/settings/view/notification_settings_page.dart b/lib/settings/view/notification_settings_page.dart index dc781974..f0a8d1f2 100644 --- a/lib/settings/view/notification_settings_page.dart +++ b/lib/settings/view/notification_settings_page.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/settings/bloc/settings_bloc.dart'; -import 'package:ht_main/shared/constants/constants.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template notification_settings_page} /// A page for configuring notification settings. @@ -13,7 +13,7 @@ class NotificationSettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final settingsBloc = context.watch(); final state = settingsBloc.state; diff --git a/lib/settings/view/settings_page.dart b/lib/settings/view/settings_page.dart index 32f223a5..2cd36e7b 100644 --- a/lib/settings/view/settings_page.dart +++ b/lib/settings/view/settings_page.dart @@ -5,8 +5,8 @@ import 'package:ht_main/app/bloc/app_bloc.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; import 'package:ht_main/settings/bloc/settings_bloc.dart'; -import 'package:ht_main/shared/constants/constants.dart'; -import 'package:ht_main/shared/widgets/widgets.dart'; +import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template settings_page} /// The main page for accessing different application settings categories. @@ -20,7 +20,7 @@ class SettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; return Scaffold( appBar: AppBar( @@ -54,7 +54,9 @@ class SettingsPage extends StatelessWidget { // Handle error state if (state.status == SettingsStatus.failure) { return FailureStateWidget( - message: state.error?.toString() ?? l10n.settingsErrorDefault, + exception: + state.error as HtHttpException? ?? + const UnknownException('An unknown error occurred'), onRetry: () { // Access AppBloc to get the current user ID for retry final appBloc = context.read(); diff --git a/lib/settings/view/theme_settings_page.dart b/lib/settings/view/theme_settings_page.dart index 43a14937..a851fead 100644 --- a/lib/settings/view/theme_settings_page.dart +++ b/lib/settings/view/theme_settings_page.dart @@ -4,8 +4,8 @@ import 'package:ht_main/app/bloc/app_bloc.dart'; import 'package:ht_main/l10n/app_localizations.dart'; import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/settings/bloc/settings_bloc.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; import 'package:ht_shared/ht_shared.dart' show AppAccentTheme, AppBaseTheme; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template theme_settings_page} /// A page for configuring theme-related settings like base and accent themes. @@ -40,7 +40,7 @@ class ThemeSettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final settingsBloc = context.watch(); final state = settingsBloc.state; diff --git a/lib/shared/constants/app_spacing.dart b/lib/shared/constants/app_spacing.dart deleted file mode 100644 index ddc23d98..00000000 --- a/lib/shared/constants/app_spacing.dart +++ /dev/null @@ -1,40 +0,0 @@ -/// Defines standard spacing constants used throughout the application. -/// -/// Consistent spacing is crucial for a clean and professional UI. -/// Using these constants ensures uniformity and makes global -/// adjustments easier. -abstract final class AppSpacing { - /// Extra small spacing value (e.g., 4.0). - static const double xs = 4; - - /// Small spacing value (e.g., 8.0). - static const double sm = 8; - - /// Medium spacing value (e.g., 12.0). - static const double md = 12; - - /// Large spacing value (e.g., 16.0). - static const double lg = 16; - - /// Extra large spacing value (e.g., 24.0). - static const double xl = 24; - - /// Extra extra large spacing value (e.g., 32.0). - static const double xxl = 32; - - // --- Padding Specific --- - // While the above can be used for padding, specific names can - // improve clarity. - - /// Small padding value (equivalent to sm). - static const double paddingSmall = sm; - - /// Medium padding value (equivalent to md). - static const double paddingMedium = md; - - /// Large padding value (equivalent to lg). - static const double paddingLarge = lg; - - /// Extra large padding value (equivalent to xl). - static const double paddingExtraLarge = xl; -} diff --git a/lib/shared/constants/constants.dart b/lib/shared/constants/constants.dart deleted file mode 100644 index e92c41a1..00000000 --- a/lib/shared/constants/constants.dart +++ /dev/null @@ -1,5 +0,0 @@ -/// Barrel file for shared constants. -/// Exports application-wide constant definitions. -library; - -export 'app_spacing.dart'; diff --git a/lib/shared/extensions/content_type_extensions.dart b/lib/shared/extensions/content_type_extensions.dart new file mode 100644 index 00000000..8cf12a80 --- /dev/null +++ b/lib/shared/extensions/content_type_extensions.dart @@ -0,0 +1,21 @@ +import 'package:flutter/widgets.dart'; +import 'package:ht_main/l10n/l10n.dart'; +import 'package:ht_shared/ht_shared.dart'; + +/// An extension on [ContentType] to provide localized display names. +extension ContentTypeX on ContentType { + /// Returns a user-friendly, localized display name for the content type. + String displayName(BuildContext context) { + switch (this) { + case ContentType.headline: + return context.l10n.contentTypeHeadline; + case ContentType.topic: + return context.l10n.contentTypeTopic; + case ContentType.source: + return context.l10n.contentTypeSource; + case ContentType.country: + // While not searchable, providing a name is good practice. + return context.l10n.contentTypeCountry; + } + } +} diff --git a/lib/shared/localization/ar_timeago_messages.dart b/lib/shared/localization/ar_timeago_messages.dart deleted file mode 100644 index a4de4608..00000000 --- a/lib/shared/localization/ar_timeago_messages.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:timeago/timeago.dart' as timeago; - -/// Custom Arabic lookup messages for the timeago package. -class ArTimeagoMessages implements timeago.LookupMessages { - @override - String prefixAgo() => ''; - @override - String prefixFromNow() => 'بعد '; - @override - String suffixAgo() => ''; - @override - String suffixFromNow() => ''; - - @override - String lessThanOneMinute(int seconds) => 'الآن'; - @override - String aboutAMinute(int minutes) => 'منذ 1د'; - @override - String minutes(int minutes) => 'منذ $minutesد'; - - @override - String aboutAnHour(int minutes) => 'منذ 1س'; - @override - String hours(int hours) => 'منذ $hoursس'; - - @override - String aDay(int hours) => 'منذ 1ي'; - @override - String days(int days) => 'منذ $daysي'; - - @override - String aboutAMonth(int days) => 'منذ 1ش'; - @override - String months(int months) => 'منذ $monthsش'; - - @override - String aboutAYear(int year) => 'منذ 1سنة'; - @override - String years(int years) => 'منذ $yearsسنوات'; - - @override - String wordSeparator() => ' '; -} diff --git a/lib/shared/localization/en_timeago_messages.dart b/lib/shared/localization/en_timeago_messages.dart deleted file mode 100644 index f3f284aa..00000000 --- a/lib/shared/localization/en_timeago_messages.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:timeago/timeago.dart' as timeago; - -/// Custom English lookup messages for the timeago package (concise). -class EnTimeagoMessages implements timeago.LookupMessages { - @override - String prefixAgo() => ''; - @override - String prefixFromNow() => ''; - @override - String suffixAgo() => ' ago'; - @override - String suffixFromNow() => ' from now'; - - @override - String lessThanOneMinute(int seconds) => 'now'; - @override - String aboutAMinute(int minutes) => '1m'; - @override - String minutes(int minutes) => '${minutes}m'; - - @override - String aboutAnHour(int minutes) => '1h'; - @override - String hours(int hours) => '${hours}h'; - - @override - String aDay(int hours) => '1d'; - @override - String days(int days) => '${days}d'; - - @override - String aboutAMonth(int days) => '1mo'; - @override - String months(int months) => '${months}mo'; - - @override - String aboutAYear(int year) => '1y'; - @override - String years(int years) => '${years}y'; - - @override - String wordSeparator() => ' '; -} diff --git a/lib/shared/services/feed_injector_service.dart b/lib/shared/services/feed_injector_service.dart index 2c1d5614..eb83e3f0 100644 --- a/lib/shared/services/feed_injector_service.dart +++ b/lib/shared/services/feed_injector_service.dart @@ -1,64 +1,59 @@ -import 'dart:math'; - import 'package:ht_shared/ht_shared.dart'; +import 'package:uuid/uuid.dart'; /// A service responsible for injecting various types of FeedItems (like Ads -/// and AccountActions) into a list of primary content items (e.g., Headlines). +/// and FeedActions) into a list of primary content items (e.g., Headlines). class FeedInjectorService { - final Random _random = Random(); + final Uuid _uuid = const Uuid(); /// Processes a list of [Headline] items and injects [Ad] and - /// [AccountAction] items based on the provided configurations and user state. + /// [FeedAction] items based on the provided configurations and user state. /// /// Parameters: /// - `headlines`: The list of original [Headline] items. /// - `user`: The current [User] object (nullable). This is used to determine - /// user role for ad frequency and account action relevance. - /// - `appConfig`: The application's configuration ([AppConfig]), which contains - /// [AdConfig] for ad injection rules and [AccountActionConfig] for - /// account action rules. + /// user role for ad frequency and feed action relevance. + /// - `remoteConfig`: The application's remote configuration ([RemoteConfig]), + /// which contains [AdConfig] for ad injection rules and + /// [AccountActionConfig] for feed action rules. /// - `currentFeedItemCount`: The total number of items already present in the /// feed before this batch of headlines is processed. This is crucial for /// correctly applying ad frequency and placement intervals, especially /// during pagination. Defaults to 0 for the first batch. /// /// Returns a new list of [FeedItem] objects, interspersed with ads and - /// account actions according to the defined logic. + /// feed actions according to the defined logic. List injectItems({ required List headlines, required User? user, - required AppConfig appConfig, + required RemoteConfig remoteConfig, int currentFeedItemCount = 0, }) { final finalFeed = []; - var accountActionInjectedThisBatch = false; + var feedActionInjectedThisBatch = false; var headlinesInThisBatchCount = 0; - final adConfig = appConfig.adConfig; - final userRole = user?.role ?? UserRole.guestUser; + final adConfig = remoteConfig.adConfig; + final userRole = user?.appRole ?? AppUserRole.guestUser; int adFrequency; int adPlacementInterval; switch (userRole) { - case UserRole.guestUser: + case AppUserRole.guestUser: adFrequency = adConfig.guestAdFrequency; adPlacementInterval = adConfig.guestAdPlacementInterval; - case UserRole.standardUser: // Assuming 'authenticated' maps to standard + case AppUserRole.standardUser: adFrequency = adConfig.authenticatedAdFrequency; adPlacementInterval = adConfig.authenticatedAdPlacementInterval; - case UserRole.premiumUser: + case AppUserRole.premiumUser: adFrequency = adConfig.premiumAdFrequency; adPlacementInterval = adConfig.premiumAdPlacementInterval; - // ignore: no_default_cases - default: // For any other roles, or if UserRole enum expands - adFrequency = adConfig.guestAdFrequency; - adPlacementInterval = adConfig.guestAdPlacementInterval; } - // Determine if an AccountAction is due before iterating - final accountActionToInject = _getDueAccountActionDetails( + // Determine if a FeedAction is due before iterating + final feedActionToInject = _getDueFeedAction( user: user, - appConfig: appConfig, + remoteConfig: remoteConfig, ); for (var i = 0; i < headlines.length; i++) { @@ -68,20 +63,17 @@ class FeedInjectorService { final totalItemsSoFar = currentFeedItemCount + finalFeed.length; - // 1. Inject AccountAction (if due and not already injected in this batch) + // 1. Inject FeedAction (if due and not already injected in this batch) // Attempt to inject after the first headline of the current batch. if (i == 0 && - accountActionToInject != null && - !accountActionInjectedThisBatch) { - finalFeed.add(accountActionToInject); - accountActionInjectedThisBatch = true; - // Note: AccountAction also counts as an item for ad placement interval + feedActionToInject != null && + !feedActionInjectedThisBatch) { + finalFeed.add(feedActionToInject); + feedActionInjectedThisBatch = true; } // 2. Inject Ad if (adFrequency > 0 && totalItemsSoFar >= adPlacementInterval) { - // Check frequency against headlines processed *in this batch* after interval met - // This is a simplified local frequency. A global counter might be needed for strict global frequency. if (headlinesInThisBatchCount % adFrequency == 0) { final adToInject = _getAdToInject(); if (adToInject != null) { @@ -93,134 +85,109 @@ class FeedInjectorService { return finalFeed; } - AccountAction? _getDueAccountActionDetails({ + FeedAction? _getDueFeedAction({ required User? user, - required AppConfig appConfig, + required RemoteConfig remoteConfig, }) { - final userRole = user?.role ?? UserRole.guestUser; + final userRole = user?.appRole ?? AppUserRole.guestUser; final now = DateTime.now(); - final lastActionShown = user?.lastAccountActionShownAt; - final daysBetweenActionsConfig = appConfig.accountActionConfig; - - int daysThreshold; - AccountActionType? actionType; - - if (userRole == UserRole.guestUser) { - daysThreshold = daysBetweenActionsConfig.guestDaysBetweenAccountActions; - actionType = AccountActionType.linkAccount; - } else if (userRole == UserRole.standardUser) { - daysThreshold = - daysBetweenActionsConfig.standardUserDaysBetweenAccountActions; - - // todo(fulleni): once account upgrade feature is implemented, - // uncomment the action type line - // and remove teh null return line. - - // actionType = AccountActionType.upgrade; - return null; - } else { - // No account actions for premium users or other roles for now - return null; - } + final actionConfig = remoteConfig.accountActionConfig; + + // Iterate through all possible action types to find one that is due. + for (final actionType in FeedActionType.values) { + final status = user?.feedActionStatus[actionType]; + + // Skip if the action has already been completed. + if (status?.isCompleted ?? false) { + continue; + } + + final daysBetweenActionsMap = (userRole == AppUserRole.guestUser) + ? actionConfig.guestDaysBetweenActions + : actionConfig.standardUserDaysBetweenActions; - if (lastActionShown == null || - now.difference(lastActionShown).inDays >= daysThreshold) { - if (actionType == AccountActionType.linkAccount) { - return _buildLinkAccountActionVariant(appConfig); - } else if (actionType == AccountActionType.upgrade) { - return _buildUpgradeAccountActionVariant(appConfig); + final daysThreshold = daysBetweenActionsMap[actionType]; + + // Skip if there's no configuration for this action type for the user's role. + if (daysThreshold == null) { + continue; + } + + final lastShown = status?.lastShownAt; + + // Check if the cooldown period has passed. + if (lastShown == null || + now.difference(lastShown).inDays >= daysThreshold) { + // Found a due action, build and return it. + return _buildFeedActionVariant(actionType); } } + + // No actions are due at this time. return null; } - AccountAction _buildLinkAccountActionVariant(AppConfig appConfig) { - // final prefs = appConfig.userPreferenceLimits; - // final ads = appConfig.adConfig; - final variant = _random.nextInt(3); - + FeedAction _buildFeedActionVariant(FeedActionType actionType) { String title; String description; - var ctaText = 'Learn More'; + String ctaText; + String ctaUrl; - switch (variant) { - case 0: + // TODO(anyone): Use a random variant selection for more dynamic content. + switch (actionType) { + case FeedActionType.linkAccount: title = 'Unlock Your Full Potential!'; description = 'Link your account to enjoy expanded content access, keep your preferences synced, and experience a more streamlined ad display.'; ctaText = 'Link Account & Explore'; - case 1: - title = 'Personalize Your Experience!'; - description = - 'Secure your settings and reading history across all your devices by linking your account. Enjoy a tailored news journey!'; - ctaText = 'Secure My Preferences'; - default: // case 2 - title = 'Get More From Your News!'; - description = - 'Link your account for enhanced content limits, better ad experiences, and ensure your preferences are always with you.'; - ctaText = 'Get Started'; - } - - return AccountAction( - title: title, - description: description, - accountActionType: AccountActionType.linkAccount, - callToActionText: ctaText, - // The actual navigation for linking is typically handled by the UI - // when this action item is tapped. The URL can be a deep link or a route. - callToActionUrl: '/authentication?context=linking', - ); - } - - AccountAction _buildUpgradeAccountActionVariant(AppConfig appConfig) { - // final prefs = appConfig.userPreferenceLimits; - // final ads = appConfig.adConfig; - final variant = _random.nextInt(3); - - String title; - String description; - var ctaText = 'Explore Premium'; - - switch (variant) { - case 0: + ctaUrl = '/authentication?context=linking'; + case FeedActionType.upgrade: title = 'Unlock Our Best Features!'; description = 'Go Premium to enjoy our most comprehensive content access, the best ad experience, and many more exclusive perks.'; ctaText = 'Upgrade Now'; - case 1: - title = 'Elevate Your News Consumption!'; - description = - 'With Premium, your content limits are greatly expanded and you will enjoy our most favorable ad settings. Discover the difference!'; - ctaText = 'Discover Premium Benefits'; - default: // case 2 - title = 'Want More Control & Fewer Interruptions?'; - description = - 'Upgrade to Premium for a superior ad experience, massively increased content limits, and a more focused news journey.'; - ctaText = 'Yes, Upgrade Me!'; + ctaUrl = '/account/upgrade'; + case FeedActionType.rateApp: + title = 'Enjoying the App?'; + description = 'A rating on the app store helps us grow.'; + ctaText = 'Rate Us'; + ctaUrl = '/app-store-rating'; // Placeholder + case FeedActionType.enableNotifications: + title = 'Stay Updated!'; + description = 'Enable notifications to get the latest news instantly.'; + ctaText = 'Enable Notifications'; + ctaUrl = '/settings/notifications'; + case FeedActionType.followTopics: + title = 'Personalize Your Feed'; + description = 'Follow topics to see more of what you love.'; + ctaText = 'Follow Topics'; + ctaUrl = '/account/manage-followed-items/topics'; + case FeedActionType.followSources: + title = 'Discover Your Favorite Sources'; + description = 'Follow sources to get news from who you trust.'; + ctaText = 'Follow Sources'; + ctaUrl = '/account/manage-followed-items/sources'; } - return AccountAction( + + return FeedAction( + id: _uuid.v4(), title: title, description: description, - accountActionType: AccountActionType.upgrade, + feedActionType: actionType, callToActionText: ctaText, - // URL could point to a subscription page/flow - callToActionUrl: '/account/upgrade', + callToActionUrl: ctaUrl, ); } - // Placeholder for _getAdToInject Ad? _getAdToInject() { // For now, return a placeholder Ad, always native. // In a real scenario, this would fetch from an ad network or predefined list. - // final adPlacements = AdPlacement.values; - return Ad( - // id is generated by model if not provided + id: _uuid.v4(), imageUrl: 'https://via.placeholder.com/300x100.png/000000/FFFFFF?Text=Native+Placeholder+Ad', targetUrl: 'https://example.com/adtarget', adType: AdType.native, - // Default placement or random from native-compatible placements placement: AdPlacement.feedInlineNativeBanner, ); } diff --git a/lib/shared/services/services.dart b/lib/shared/services/services.dart new file mode 100644 index 00000000..b314f613 --- /dev/null +++ b/lib/shared/services/services.dart @@ -0,0 +1 @@ +export 'feed_injector_service.dart'; diff --git a/lib/shared/shared.dart b/lib/shared/shared.dart index a0f4b74e..31319422 100644 --- a/lib/shared/shared.dart +++ b/lib/shared/shared.dart @@ -1,10 +1,2 @@ -/// Barrel file for the shared library. -/// -/// Exports common constants, theme elements, and widgets used across -/// the application to promote consistency and maintainability. -library; - -export 'constants/constants.dart'; -export 'theme/theme.dart'; -export 'utils/utils.dart'; +export 'services/services.dart'; export 'widgets/widgets.dart'; diff --git a/lib/shared/theme/app_theme.dart b/lib/shared/theme/app_theme.dart deleted file mode 100644 index 96a701af..00000000 --- a/lib/shared/theme/app_theme.dart +++ /dev/null @@ -1,211 +0,0 @@ -// -// ignore_for_file: lines_longer_than_80_chars - -import 'package:flex_color_scheme/flex_color_scheme.dart'; -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:ht_shared/ht_shared.dart'; - -// --- Common Sub-theme Settings --- -// Defines customizations for various components, shared between light/dark themes. -const FlexSubThemesData _commonSubThemesData = FlexSubThemesData( - // --- Card Theme --- - // Slightly rounded corners for cards (headline items) - cardRadius: 8, - // Use default elevation or specify if needed: cardElevation: 2.0, - - // --- AppBar Theme --- - // Example: Use scheme surface color for app bar (often less distracting) - appBarBackgroundSchemeColor: SchemeColor.surface, - // Or keep default: appBarBackgroundSchemeColor: SchemeColor.primary, - // Example: Center title? appBarCenterTitle: true, - - // --- Input Decorator (for Search TextField) --- - // Example: Add a border radius - inputDecoratorRadius: 8, - // Example: Use outline border (common modern style) - inputDecoratorIsFilled: false, - inputDecoratorBorderType: FlexInputBorderType.outline, - - // Add other component themes as needed (Buttons, Dialogs, etc.) -); - -// Helper function to apply common text theme customizations -TextTheme _customizeTextTheme( - TextTheme baseTextTheme, { - required AppTextScaleFactor appTextScaleFactor, - required AppFontWeight appFontWeight, -}) { - print( - '[_customizeTextTheme] Received appFontWeight: $appFontWeight, appTextScaleFactor: $appTextScaleFactor', - ); - // Define font size factors - double factor; - switch (appTextScaleFactor) { - case AppTextScaleFactor.small: - factor = 0.85; - case AppTextScaleFactor.large: - factor = 1.15; - case AppTextScaleFactor.medium: - factor = 1.0; - case AppTextScaleFactor.extraLarge: - factor = 1.3; - } - - // Helper to apply factor safely - double? applyFactor(double? baseSize) => - baseSize != null ? (baseSize * factor).roundToDouble() : null; - - // Map AppFontWeight to FontWeight - FontWeight selectedFontWeight; - switch (appFontWeight) { - case AppFontWeight.light: - selectedFontWeight = FontWeight.w300; - case AppFontWeight.regular: - selectedFontWeight = FontWeight.w400; - case AppFontWeight.bold: - selectedFontWeight = FontWeight.w700; - } - print( - '[_customizeTextTheme] Mapped to selectedFontWeight: $selectedFontWeight', - ); - - return baseTextTheme.copyWith( - // --- Headline/Title Styles --- - // Headlines and titles often have their own explicit weights, - // but we can make them configurable if needed. For now, let's assume - // body text is the primary target for user-defined weight. - headlineLarge: baseTextTheme.headlineLarge?.copyWith( - fontSize: applyFactor(28), - fontWeight: FontWeight.bold, - ), - headlineMedium: baseTextTheme.headlineMedium?.copyWith( - fontSize: applyFactor(24), - fontWeight: FontWeight.bold, - ), - titleLarge: baseTextTheme.titleLarge?.copyWith( - fontSize: applyFactor(18), - fontWeight: FontWeight.w600, - ), - titleMedium: baseTextTheme.titleMedium?.copyWith( - fontSize: applyFactor(16), - fontWeight: FontWeight.w600, - ), - - // --- Body/Content Styles --- - // Apply user-selected font weight to body text - bodyLarge: baseTextTheme.bodyLarge?.copyWith( - fontSize: applyFactor(16), - height: 1.5, - fontWeight: selectedFontWeight, - ), - bodyMedium: baseTextTheme.bodyMedium?.copyWith( - fontSize: applyFactor(14), - height: 1.4, - fontWeight: selectedFontWeight, - ), - - // --- Metadata/Caption Styles --- - // Captions might also benefit from user-defined weight or stay regular. - labelSmall: baseTextTheme.labelSmall?.copyWith( - fontSize: applyFactor(12), - fontWeight: selectedFontWeight, - ), - - // --- Button Style (Usually default is fine) --- - // labelLarge: baseTextTheme.labelLarge?.copyWith(fontSize: 14, fontWeight: FontWeight.bold), - ); -} - -// Helper function to get the appropriate GoogleFonts text theme function -// based on the provided font family name. -// Corrected return type to match GoogleFonts functions (positional optional) -TextTheme Function([TextTheme?]) _getGoogleFontTextTheme(String? fontFamily) { - print('[_getGoogleFontTextTheme] Received fontFamily: $fontFamily'); - switch (fontFamily) { - case 'Roboto': - print('[_getGoogleFontTextTheme] Returning GoogleFonts.robotoTextTheme'); - return GoogleFonts.robotoTextTheme; - case 'OpenSans': - print( - '[_getGoogleFontTextTheme] Returning GoogleFonts.openSansTextTheme', - ); - return GoogleFonts.openSansTextTheme; - case 'Lato': - print('[_getGoogleFontTextTheme] Returning GoogleFonts.latoTextTheme'); - return GoogleFonts.latoTextTheme; - case 'Montserrat': - print( - '[_getGoogleFontTextTheme] Returning GoogleFonts.montserratTextTheme', - ); - return GoogleFonts.montserratTextTheme; - case 'Merriweather': - print( - '[_getGoogleFontTextTheme] Returning GoogleFonts.merriweatherTextTheme', - ); - return GoogleFonts.merriweatherTextTheme; - case 'SystemDefault': - case null: - default: - print( - '[_getGoogleFontTextTheme] Defaulting to GoogleFonts.notoSansTextTheme for input: $fontFamily', - ); - return GoogleFonts.notoSansTextTheme; - } -} - -/// Defines the application's light theme using FlexColorScheme. -/// -/// Takes the active [scheme], [appTextScaleFactor], [appFontWeight], and optional [fontFamily]. -ThemeData lightTheme({ - required FlexScheme scheme, - required AppTextScaleFactor appTextScaleFactor, - required AppFontWeight appFontWeight, - String? fontFamily, -}) { - print( - '[AppTheme.lightTheme] Received scheme: $scheme, appTextScaleFactor: $appTextScaleFactor, appFontWeight: $appFontWeight, fontFamily: $fontFamily', - ); - final textThemeGetter = _getGoogleFontTextTheme(fontFamily); - final baseTextTheme = textThemeGetter(); - - return FlexThemeData.light( - scheme: scheme, - fontFamily: fontFamily, - textTheme: _customizeTextTheme( - baseTextTheme, - appTextScaleFactor: appTextScaleFactor, - appFontWeight: appFontWeight, - ), - subThemesData: _commonSubThemesData, - ); -} - -/// Defines the application's dark theme using FlexColorScheme. -/// -/// Takes the active [scheme], [appTextScaleFactor], [appFontWeight], and optional [fontFamily]. -ThemeData darkTheme({ - required FlexScheme scheme, - required AppTextScaleFactor appTextScaleFactor, - required AppFontWeight appFontWeight, - String? fontFamily, -}) { - print( - '[AppTheme.darkTheme] Received scheme: $scheme, appTextScaleFactor: $appTextScaleFactor, appFontWeight: $appFontWeight, fontFamily: $fontFamily', - ); - final textThemeGetter = _getGoogleFontTextTheme(fontFamily); - final baseTextTheme = textThemeGetter( - ThemeData(brightness: Brightness.dark).textTheme, - ); - - return FlexThemeData.dark( - scheme: scheme, - fontFamily: fontFamily, - textTheme: _customizeTextTheme( - baseTextTheme, - appTextScaleFactor: appTextScaleFactor, - appFontWeight: appFontWeight, - ), - subThemesData: _commonSubThemesData, - ); -} diff --git a/lib/shared/theme/theme.dart b/lib/shared/theme/theme.dart deleted file mode 100644 index 5d889ee2..00000000 --- a/lib/shared/theme/theme.dart +++ /dev/null @@ -1,5 +0,0 @@ -/// Barrel file for shared theme elements. -/// Exports application-wide theme definitions like colors and theme data. -library; - -export 'app_theme.dart'; diff --git a/lib/shared/utils/date_formatter.dart b/lib/shared/utils/date_formatter.dart deleted file mode 100644 index 3ac01296..00000000 --- a/lib/shared/utils/date_formatter.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:timeago/timeago.dart' as timeago; - -/// Formats the given [dateTime] into a relative time string -/// (e.g., "5m ago", "Yesterday", "now"). -/// -/// Uses the current locale from [context] to format appropriately. -/// Returns an empty string if [dateTime] is null. -String formatRelativeTime(BuildContext context, DateTime? dateTime) { - if (dateTime == null) { - return ''; - } - final locale = Localizations.localeOf(context).languageCode; - return timeago.format(dateTime, locale: locale); -} diff --git a/lib/shared/utils/utils.dart b/lib/shared/utils/utils.dart deleted file mode 100644 index 46a14dda..00000000 --- a/lib/shared/utils/utils.dart +++ /dev/null @@ -1,4 +0,0 @@ -/// Barrel file for shared utility functions. -library; - -export 'date_formatter.dart'; diff --git a/lib/shared/widgets/failure_state_widget.dart b/lib/shared/widgets/failure_state_widget.dart deleted file mode 100644 index 4bea388a..00000000 --- a/lib/shared/widgets/failure_state_widget.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; - -/// A widget to display an error message and an optional retry button. -class FailureStateWidget extends StatelessWidget { - /// Creates a [FailureStateWidget]. - /// - /// The [message] is the error message to display. - /// - /// The [onRetry] is an optional callback to be called - /// when the retry button is pressed. - const FailureStateWidget({ - required this.message, - super.key, - this.onRetry, - this.retryButtonText, - }); - - /// The error message to display. - final String message; - - /// An optional callback to be called when the retry button is pressed. - final VoidCallback? onRetry; - - /// Optional custom text for the retry button. Defaults to "Retry". - final String? retryButtonText; - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - message, - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - // Show the retry button only if onRetry is provided - if (onRetry != null) - Padding( - padding: const EdgeInsets.only(top: 16), - child: ElevatedButton( - onPressed: onRetry, - child: Text(retryButtonText ?? 'Retry'), - ), - ), - ], - ), - ); - } -} diff --git a/lib/shared/widgets/headline_tile_image_start.dart b/lib/shared/widgets/headline_tile_image_start.dart index 01bc0a33..45a08531 100644 --- a/lib/shared/widgets/headline_tile_image_start.dart +++ b/lib/shared/widgets/headline_tile_image_start.dart @@ -1,14 +1,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:ht_main/entity_details/models/entity_type.dart'; import 'package:ht_main/entity_details/view/entity_details_page.dart'; -import 'package:ht_main/l10n/app_localizations.dart'; -import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; -import 'package:ht_main/shared/utils/utils.dart'; -import 'package:ht_shared/ht_shared.dart' show Headline; -// timeago import removed from here, handled by utility +import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template headline_tile_image_start} /// A shared widget to display a headline item with a small image at the start. @@ -34,14 +29,13 @@ class HeadlineTileImageStart extends StatelessWidget { final Widget? trailing; /// The type of the entity currently being viewed in detail (e.g., on a category page). - final EntityType? currentContextEntityType; + final ContentType? currentContextEntityType; /// The ID of the entity currently being viewed in detail. final String? currentContextEntityId; @override Widget build(BuildContext context) { - final l10n = context.l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; @@ -52,53 +46,41 @@ class HeadlineTileImageStart extends StatelessWidget { vertical: AppSpacing.xs, ), child: InkWell( - onTap: onHeadlineTap, + onTap: onHeadlineTap, // Main tap for image + title area child: Padding( padding: const EdgeInsets.all(AppSpacing.md), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - width: 72, + width: 72, // Standard small image size height: 72, child: ClipRRect( borderRadius: BorderRadius.circular(AppSpacing.xs), - child: headline.imageUrl != null - ? Image.network( - headline.imageUrl!, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return ColoredBox( - color: colorScheme.surfaceContainerHighest, - child: const Center( - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ); - }, - errorBuilder: (context, error, stackTrace) => - ColoredBox( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.broken_image_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xl, - ), - ), - ) - : ColoredBox( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.image_not_supported_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xl, - ), + child: Image.network( + headline.imageUrl, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return ColoredBox( + color: colorScheme.surfaceContainerHighest, + child: const Center( + child: CircularProgressIndicator(strokeWidth: 2), ), + ); + }, + errorBuilder: (context, error, stackTrace) => ColoredBox( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.broken_image_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.xl, + ), + ), + ), ), ), - const SizedBox(width: AppSpacing.md), + const SizedBox(width: AppSpacing.md), // Always add spacing Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -114,11 +96,12 @@ class HeadlineTileImageStart extends StatelessWidget { const SizedBox(height: AppSpacing.sm), _HeadlineMetadataRow( headline: headline, - l10n: l10n, colorScheme: colorScheme, textTheme: textTheme, - currentContextEntityType: currentContextEntityType, - currentContextEntityId: currentContextEntityId, + currentContextEntityType: + currentContextEntityType, // Pass down + currentContextEntityId: + currentContextEntityId, // Pass down ), ], ), @@ -139,7 +122,6 @@ class HeadlineTileImageStart extends StatelessWidget { class _HeadlineMetadataRow extends StatelessWidget { const _HeadlineMetadataRow({ required this.headline, - required this.l10n, required this.colorScheme, required this.textTheme, this.currentContextEntityType, @@ -147,15 +129,15 @@ class _HeadlineMetadataRow extends StatelessWidget { }); final Headline headline; - final AppLocalizations l10n; final ColorScheme colorScheme; final TextTheme textTheme; - final EntityType? currentContextEntityType; + final ContentType? currentContextEntityType; final String? currentContextEntityId; @override Widget build(BuildContext context) { - final formattedDate = formatRelativeTime(context, headline.publishedAt); + // TODO(anyone): Use a proper timeago library. + final formattedDate = headline.createdAt.toString(); // Use bodySmall for a reasonable base size, with muted accent color final metadataTextStyle = textTheme.bodySmall?.copyWith( @@ -163,10 +145,10 @@ class _HeadlineMetadataRow extends StatelessWidget { ); // Icon color to match the subtle text final iconColor = colorScheme.primary.withOpacity(0.7); - const iconSize = AppSpacing.sm; + const iconSize = AppSpacing.sm; // Standard small icon size return Wrap( - spacing: AppSpacing.sm, + spacing: AppSpacing.sm, // Increased spacing for readability runSpacing: AppSpacing.xs, crossAxisAlignment: WrapCrossAlignment.center, children: [ @@ -183,10 +165,10 @@ class _HeadlineMetadataRow extends StatelessWidget { Text(formattedDate, style: metadataTextStyle), ], ), - // Conditionally render Category as Text - if (headline.category?.name != null && - !(currentContextEntityType == EntityType.category && - headline.category!.id == currentContextEntityId)) ...[ + // Conditionally render Topic as Text + if (headline.topic.name.isNotEmpty && + !(currentContextEntityType == ContentType.topic && + headline.topic.id == currentContextEntityId)) ...[ if (formattedDate.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), @@ -194,38 +176,39 @@ class _HeadlineMetadataRow extends StatelessWidget { ), GestureDetector( onTap: () { - if (headline.category != null) { - context.push( - Routes.categoryDetails, - extra: EntityDetailsPageArguments(entity: headline.category), - ); - } + context.push( + Routes.topicDetails, + extra: EntityDetailsPageArguments( + entity: headline.topic, + contentType: ContentType.topic, + ), + ); }, - child: Text(headline.category!.name, style: metadataTextStyle), + child: Text(headline.topic.name, style: metadataTextStyle), ), ], // Conditionally render Source as Text - if (headline.source?.name != null && - !(currentContextEntityType == EntityType.source && - headline.source!.id == currentContextEntityId)) ...[ + if (!(currentContextEntityType == ContentType.source && + headline.source.id == currentContextEntityId)) ...[ if (formattedDate.isNotEmpty || - (headline.category?.name != null && - !(currentContextEntityType == EntityType.category && - headline.category!.id == currentContextEntityId))) + (headline.topic.name.isNotEmpty && + !(currentContextEntityType == ContentType.topic && + headline.topic.id == currentContextEntityId))) Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), child: Text('•', style: metadataTextStyle), ), GestureDetector( onTap: () { - if (headline.source != null) { - context.push( - Routes.sourceDetails, - extra: EntityDetailsPageArguments(entity: headline.source), - ); - } + context.push( + Routes.sourceDetails, + extra: EntityDetailsPageArguments( + entity: headline.source, + contentType: ContentType.source, + ), + ); }, - child: Text(headline.source!.name, style: metadataTextStyle), + child: Text(headline.source.name, style: metadataTextStyle), ), ], ], diff --git a/lib/shared/widgets/headline_tile_image_top.dart b/lib/shared/widgets/headline_tile_image_top.dart index 3985acd8..440de329 100644 --- a/lib/shared/widgets/headline_tile_image_top.dart +++ b/lib/shared/widgets/headline_tile_image_top.dart @@ -1,14 +1,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:ht_main/entity_details/models/entity_type.dart'; import 'package:ht_main/entity_details/view/entity_details_page.dart'; -import 'package:ht_main/l10n/app_localizations.dart'; -import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; -import 'package:ht_main/shared/utils/utils.dart'; -import 'package:ht_shared/ht_shared.dart' show Headline; -// timeago import removed from here, handled by utility +import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template headline_tile_image_top} /// A shared widget to display a headline item with a large image at the top. @@ -34,14 +29,13 @@ class HeadlineTileImageTop extends StatelessWidget { final Widget? trailing; /// The type of the entity currently being viewed in detail (e.g., on a category page). - final EntityType? currentContextEntityType; + final ContentType? currentContextEntityType; /// The ID of the entity currently being viewed in detail. final String? currentContextEntityId; @override Widget build(BuildContext context) { - final l10n = context.l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; @@ -55,50 +49,39 @@ class HeadlineTileImageTop extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ InkWell( - onTap: onHeadlineTap, + onTap: onHeadlineTap, // Image area is part of the main tap area child: ClipRRect( borderRadius: const BorderRadius.only( topLeft: Radius.circular(AppSpacing.xs), topRight: Radius.circular(AppSpacing.xs), ), - child: headline.imageUrl != null - ? Image.network( - headline.imageUrl!, - width: double.infinity, - height: 180, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - width: double.infinity, - height: 180, - color: colorScheme.surfaceContainerHighest, - child: const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - }, - errorBuilder: (context, error, stackTrace) => Container( - width: double.infinity, - height: 180, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.broken_image_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xxl, - ), - ), - ) - : Container( - width: double.infinity, - height: 180, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.image_not_supported_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xxl, - ), + child: Image.network( + headline.imageUrl, + width: double.infinity, + height: 180, // Standard large image height + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + width: double.infinity, + height: 180, + color: colorScheme.surfaceContainerHighest, + child: const Center( + child: CircularProgressIndicator(strokeWidth: 2), ), + ); + }, + errorBuilder: (context, error, stackTrace) => Container( + width: double.infinity, + height: 180, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.broken_image_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.xxl, + ), + ), + ), ), ), Padding( @@ -111,7 +94,7 @@ class HeadlineTileImageTop extends StatelessWidget { children: [ Expanded( child: InkWell( - onTap: onHeadlineTap, + onTap: onHeadlineTap, // Title is part of main tap area child: Text( headline.title, style: textTheme.titleMedium?.copyWith( @@ -131,11 +114,11 @@ class HeadlineTileImageTop extends StatelessWidget { const SizedBox(height: AppSpacing.sm), _HeadlineMetadataRow( headline: headline, - l10n: l10n, colorScheme: colorScheme, textTheme: textTheme, - currentContextEntityType: currentContextEntityType, - currentContextEntityId: currentContextEntityId, + currentContextEntityType: + currentContextEntityType, // Pass down + currentContextEntityId: currentContextEntityId, // Pass down ), ], ), @@ -150,7 +133,6 @@ class HeadlineTileImageTop extends StatelessWidget { class _HeadlineMetadataRow extends StatelessWidget { const _HeadlineMetadataRow({ required this.headline, - required this.l10n, required this.colorScheme, required this.textTheme, this.currentContextEntityType, @@ -158,15 +140,15 @@ class _HeadlineMetadataRow extends StatelessWidget { }); final Headline headline; - final AppLocalizations l10n; final ColorScheme colorScheme; final TextTheme textTheme; - final EntityType? currentContextEntityType; + final ContentType? currentContextEntityType; final String? currentContextEntityId; @override Widget build(BuildContext context) { - final formattedDate = formatRelativeTime(context, headline.publishedAt); + // TODO(anyone): Use a proper timeago library. + final formattedDate = headline.createdAt.toString(); // Use bodySmall for a reasonable base size, with muted accent color final metadataTextStyle = textTheme.bodySmall?.copyWith( @@ -174,10 +156,10 @@ class _HeadlineMetadataRow extends StatelessWidget { ); // Icon color to match the subtle text final iconColor = colorScheme.primary.withOpacity(0.7); - const iconSize = AppSpacing.sm; + const iconSize = AppSpacing.sm; // Standard small icon size return Wrap( - spacing: AppSpacing.sm, + spacing: AppSpacing.sm, // Increased spacing for readability runSpacing: AppSpacing.xs, crossAxisAlignment: WrapCrossAlignment.center, children: [ @@ -194,10 +176,10 @@ class _HeadlineMetadataRow extends StatelessWidget { Text(formattedDate, style: metadataTextStyle), ], ), - // Conditionally render Category as Text - if (headline.category?.name != null && - !(currentContextEntityType == EntityType.category && - headline.category!.id == currentContextEntityId)) ...[ + // Conditionally render Topic as Text + if (headline.topic.name.isNotEmpty && + !(currentContextEntityType == ContentType.topic && + headline.topic.id == currentContextEntityId)) ...[ if (formattedDate.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), @@ -205,38 +187,39 @@ class _HeadlineMetadataRow extends StatelessWidget { ), GestureDetector( onTap: () { - if (headline.category != null) { - context.push( - Routes.categoryDetails, - extra: EntityDetailsPageArguments(entity: headline.category), - ); - } + context.push( + Routes.topicDetails, + extra: EntityDetailsPageArguments( + entity: headline.topic, + contentType: ContentType.topic, + ), + ); }, - child: Text(headline.category!.name, style: metadataTextStyle), + child: Text(headline.topic.name, style: metadataTextStyle), ), ], // Conditionally render Source as Text - if (headline.source?.name != null && - !(currentContextEntityType == EntityType.source && - headline.source!.id == currentContextEntityId)) ...[ + if (!(currentContextEntityType == ContentType.source && + headline.source.id == currentContextEntityId)) ...[ if (formattedDate.isNotEmpty || - (headline.category?.name != null && - !(currentContextEntityType == EntityType.category && - headline.category!.id == currentContextEntityId))) + (headline.topic.name.isNotEmpty && + !(currentContextEntityType == ContentType.topic && + headline.topic.id == currentContextEntityId))) Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), child: Text('•', style: metadataTextStyle), ), GestureDetector( onTap: () { - if (headline.source != null) { - context.push( - Routes.sourceDetails, - extra: EntityDetailsPageArguments(entity: headline.source), - ); - } + context.push( + Routes.sourceDetails, + extra: EntityDetailsPageArguments( + entity: headline.source, + contentType: ContentType.source, + ), + ); }, - child: Text(headline.source!.name, style: metadataTextStyle), + child: Text(headline.source.name, style: metadataTextStyle), ), ], ], diff --git a/lib/shared/widgets/headline_tile_text_only.dart b/lib/shared/widgets/headline_tile_text_only.dart index a06948fc..3bc4c0e0 100644 --- a/lib/shared/widgets/headline_tile_text_only.dart +++ b/lib/shared/widgets/headline_tile_text_only.dart @@ -1,14 +1,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:ht_main/entity_details/models/entity_type.dart'; import 'package:ht_main/entity_details/view/entity_details_page.dart'; -import 'package:ht_main/l10n/app_localizations.dart'; -import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/router/routes.dart'; -import 'package:ht_main/shared/constants/app_spacing.dart'; -import 'package:ht_main/shared/utils/utils.dart'; -import 'package:ht_shared/ht_shared.dart' show Headline; -// timeago import removed from here, handled by utility +import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_ui_kit/ht_ui_kit.dart'; /// {@template headline_tile_text_only} /// A widget to display a headline item with text only. @@ -36,17 +31,15 @@ class HeadlineTileTextOnly extends StatelessWidget { final Widget? trailing; /// The type of the entity currently being viewed in detail (e.g., on a category page). - final EntityType? currentContextEntityType; + final ContentType? currentContextEntityType; /// The ID of the entity currently being viewed in detail. final String? currentContextEntityId; @override Widget build(BuildContext context) { - final l10n = context.l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; - final colorScheme = theme.colorScheme; return Card( margin: const EdgeInsets.symmetric( @@ -69,17 +62,18 @@ class HeadlineTileTextOnly extends StatelessWidget { style: textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w500, ), - maxLines: 3, + maxLines: 3, // Allow more lines for text-only overflow: TextOverflow.ellipsis, ), const SizedBox(height: AppSpacing.sm), _HeadlineMetadataRow( headline: headline, - l10n: l10n, - colorScheme: colorScheme, + colorScheme: theme.colorScheme, textTheme: textTheme, - currentContextEntityType: currentContextEntityType, - currentContextEntityId: currentContextEntityId, + currentContextEntityType: + currentContextEntityType, // Pass down + currentContextEntityId: + currentContextEntityId, // Pass down ), ], ), @@ -100,7 +94,6 @@ class HeadlineTileTextOnly extends StatelessWidget { class _HeadlineMetadataRow extends StatelessWidget { const _HeadlineMetadataRow({ required this.headline, - required this.l10n, required this.colorScheme, required this.textTheme, this.currentContextEntityType, @@ -108,15 +101,15 @@ class _HeadlineMetadataRow extends StatelessWidget { }); final Headline headline; - final AppLocalizations l10n; final ColorScheme colorScheme; final TextTheme textTheme; - final EntityType? currentContextEntityType; + final ContentType? currentContextEntityType; final String? currentContextEntityId; @override Widget build(BuildContext context) { - final formattedDate = formatRelativeTime(context, headline.publishedAt); + // TODO(anyone): Use a proper timeago library. + final formattedDate = headline.createdAt.toString(); // Use bodySmall for a reasonable base size, with muted accent color final metadataTextStyle = textTheme.bodySmall?.copyWith( @@ -124,10 +117,10 @@ class _HeadlineMetadataRow extends StatelessWidget { ); // Icon color to match the subtle text final iconColor = colorScheme.primary.withOpacity(0.7); - const iconSize = AppSpacing.sm; + const iconSize = AppSpacing.sm; // Standard small icon size return Wrap( - spacing: AppSpacing.sm, + spacing: AppSpacing.sm, // Increased spacing for readability runSpacing: AppSpacing.xs, crossAxisAlignment: WrapCrossAlignment.center, children: [ @@ -144,10 +137,10 @@ class _HeadlineMetadataRow extends StatelessWidget { Text(formattedDate, style: metadataTextStyle), ], ), - // Conditionally render Category as Text - if (headline.category?.name != null && - !(currentContextEntityType == EntityType.category && - headline.category!.id == currentContextEntityId)) ...[ + // Conditionally render Topic as Text + if (headline.topic.name.isNotEmpty && + !(currentContextEntityType == ContentType.topic && + headline.topic.id == currentContextEntityId)) ...[ if (formattedDate.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), @@ -155,38 +148,39 @@ class _HeadlineMetadataRow extends StatelessWidget { ), GestureDetector( onTap: () { - if (headline.category != null) { - context.push( - Routes.categoryDetails, - extra: EntityDetailsPageArguments(entity: headline.category), - ); - } + context.push( + Routes.topicDetails, + extra: EntityDetailsPageArguments( + entity: headline.topic, + contentType: ContentType.topic, + ), + ); }, - child: Text(headline.category!.name, style: metadataTextStyle), + child: Text(headline.topic.name, style: metadataTextStyle), ), ], // Conditionally render Source as Text - if (headline.source?.name != null && - !(currentContextEntityType == EntityType.source && - headline.source!.id == currentContextEntityId)) ...[ + if (!(currentContextEntityType == ContentType.source && + headline.source.id == currentContextEntityId)) ...[ if (formattedDate.isNotEmpty || - (headline.category?.name != null && - !(currentContextEntityType == EntityType.category && - headline.category!.id == currentContextEntityId))) + (headline.topic.name.isNotEmpty && + !(currentContextEntityType == ContentType.topic && + headline.topic.id == currentContextEntityId))) Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), child: Text('•', style: metadataTextStyle), ), GestureDetector( onTap: () { - if (headline.source != null) { - context.push( - Routes.sourceDetails, - extra: EntityDetailsPageArguments(entity: headline.source), - ); - } + context.push( + Routes.sourceDetails, + extra: EntityDetailsPageArguments( + entity: headline.source, + contentType: ContentType.source, + ), + ); }, - child: Text(headline.source!.name, style: metadataTextStyle), + child: Text(headline.source.name, style: metadataTextStyle), ), ], ], diff --git a/lib/shared/widgets/initial_state_widget.dart b/lib/shared/widgets/initial_state_widget.dart deleted file mode 100644 index 223b485d..00000000 --- a/lib/shared/widgets/initial_state_widget.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/material.dart'; - -class InitialStateWidget extends StatelessWidget { - const InitialStateWidget({ - required this.icon, - required this.headline, - required this.subheadline, - super.key, - }); - - final IconData icon; - final String headline; - final String subheadline; - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 64), - const SizedBox(height: 16), - Text(headline, style: const TextStyle(fontSize: 24)), - Text(subheadline), - ], - ), - ); - } -} diff --git a/lib/shared/widgets/loading_state_widget.dart b/lib/shared/widgets/loading_state_widget.dart deleted file mode 100644 index a4648740..00000000 --- a/lib/shared/widgets/loading_state_widget.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; - -class LoadingStateWidget extends StatelessWidget { - const LoadingStateWidget({ - required this.icon, - required this.headline, - required this.subheadline, - super.key, - }); - - final IconData icon; - final String headline; - final String subheadline; - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 64), - const SizedBox(height: 16), - Text(headline, style: const TextStyle(fontSize: 24)), - Text(subheadline), - const SizedBox(height: 16), - CircularProgressIndicator( - color: Theme.of(context).colorScheme.secondary, - ), - ], - ), - ); - } -} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index d75f81b2..19a5c083 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -1,10 +1,3 @@ -/// Barrel file for shared widgets. -/// Exports common, reusable UI components. -library; - -export 'failure_state_widget.dart'; export 'headline_tile_image_start.dart'; export 'headline_tile_image_top.dart'; export 'headline_tile_text_only.dart'; -export 'initial_state_widget.dart'; -export 'loading_state_widget.dart'; diff --git a/pubspec.lock b/pubspec.lock index 4194ec4f..88ec5340 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f url: "https://pub.dev" source: hosted - version: "82.0.0" + version: "85.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" + sha256: b1ade5707ab7a90dfd519eaac78a7184341d19adb6096c68d499b59c7c6cf880 url: "https://pub.dev" source: hosted - version: "7.4.5" + version: "7.7.0" ansicolor: dependency: transitive description: @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: coverage - sha256: aa07dbe5f2294c827b7edb9a87bba44a9c15a3cc81bc8da2ca19b37322d30080 + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" url: "https://pub.dev" source: hosted - version: "1.14.1" + version: "1.15.0" cross_file: dependency: transitive description: @@ -173,18 +173,18 @@ packages: dependency: transitive description: name: device_frame - sha256: d031a06f5d6f4750009672db98a5aa1536aa4a231713852469ce394779a23d75 + sha256: "7b2ebb2a09d6cc0f086b51bd1412d7be83e0170056a7290349169be41164c86a" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.0" device_preview: dependency: "direct main" description: name: device_preview - sha256: a694acdd3894b4c7d600f4ee413afc4ff917f76026b97ab06575fe886429ef19 + sha256: "88aa1cc73ee9a8ec771b309dcbc4000cc66b5d8456b825980997640ab1195bf5" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.1" diff_match_patch: dependency: transitive description: @@ -321,10 +321,10 @@ packages: dependency: transitive description: name: freezed_annotation - sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "3.1.0" frontend_server_client: dependency: transitive description: @@ -362,7 +362,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: fd31ce8e255c27e1fcc17e849c41f9c8511a6d87 + resolved-ref: a2fc2a651494831a461fff96807141bdba9cc28b url: "https://github.com/headlines-toolkit/ht-auth-api.git" source: git version: "0.0.0" @@ -371,7 +371,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: f9c3b44b79fc19dfd9b9a7e0d1e21e60f4885617 + resolved-ref: a003eb493db4fc134db419a721ee2fda0b598032 url: "https://github.com/headlines-toolkit/ht-auth-client.git" source: git version: "0.0.0" @@ -380,7 +380,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "3a8dc5ff81c59805fa59996517eb0fdf136a0b67" + resolved-ref: ea9ad0361b1e0beab4690959889b7056091d29dc url: "https://github.com/headlines-toolkit/ht-auth-inmemory" source: git version: "0.0.0" @@ -389,7 +389,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "596ba311cdbbdf61a216f60dac0218fab9e234d9" + resolved-ref: b7de5cc86d432b17710c83a1bf8de105bb4fa00d url: "https://github.com/headlines-toolkit/ht-auth-repository.git" source: git version: "0.0.0" @@ -398,7 +398,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: d64d8b8cf24f9b4bc5ad639b9c6b968807cf5d93 + resolved-ref: "16630c8717646663e52eee3ebedc4daa35ec4d39" url: "https://github.com/headlines-toolkit/ht-data-api.git" source: git version: "0.0.0" @@ -407,7 +407,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "2b9c82fc5d3aff78e1e6f51b844927508bc06e48" + resolved-ref: e566ee6eae5261c00d0987972efc77a37a0c4c3a url: "https://github.com/headlines-toolkit/ht-data-client.git" source: git version: "0.0.0" @@ -416,7 +416,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "3353842a1f1be1ab2563f2ba236daf49dd19a85e" + resolved-ref: abef81e5294d70ace82d3e87f1efc94fca6a8445 url: "https://github.com/headlines-toolkit/ht-data-inmemory.git" source: git version: "0.0.0" @@ -425,7 +425,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "2f1b1e81ff91eeae636f822edbb9088a28fd4906" + resolved-ref: f19fe64c67a2febdef853b15f6df9c63240ad48e url: "https://github.com/headlines-toolkit/ht-data-repository.git" source: git version: "0.0.0" @@ -434,7 +434,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "648e8549d7ceb2ffb293fa66bdd02f5e0ca8def6" + resolved-ref: "6484a5641c3d633d286ea5848c5b7cf1e723ebc1" url: "https://github.com/headlines-toolkit/ht-http-client.git" source: git version: "0.0.0" @@ -443,7 +443,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: e2860560d21c1cf43f0e65f28c9ba722823254f2 + resolved-ref: "2378d6698df1cdeb7c5a17470f94fb8a5a99ca01" url: "https://github.com/headlines-toolkit/ht-kv-storage-service.git" source: git version: "0.0.0" @@ -461,10 +461,19 @@ packages: description: path: "." ref: HEAD - resolved-ref: "9cbe2dcf84860f5be655d091b9e15bc1d48fa3dc" + resolved-ref: "2f78ac83c319242822250088c1edb11324404cb3" url: "https://github.com/headlines-toolkit/ht-shared.git" source: git version: "0.0.0" + ht_ui_kit: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "1c13e5accfa5fad7d262fbc5693780eac6f03fd9" + url: "https://github.com/headlines-toolkit/ht-ui-kit.git" + source: git + version: "0.0.0" html: dependency: transitive description: @@ -570,7 +579,7 @@ packages: source: hosted version: "3.0.1" logging: - dependency: transitive + dependency: "direct main" description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 @@ -733,10 +742,10 @@ packages: dependency: transitive description: name: posix - sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62 + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.0.3" provider: dependency: transitive description: @@ -986,10 +995,10 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.2" url_launcher_android: dependency: transitive description: @@ -1047,7 +1056,7 @@ packages: source: hosted version: "3.1.4" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff @@ -1122,10 +1131,10 @@ packages: dependency: transitive description: name: win32 - sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" url: "https://pub.dev" source: hosted - version: "5.13.0" + version: "5.14.0" xdg_directories: dependency: transitive description: @@ -1152,4 +1161,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.8.0 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml index bb6ebee4..106d8c84 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ dependencies: bloc_concurrency: ^0.3.0 device_preview: ^1.2.0 equatable: ^2.0.7 - flex_color_scheme: ^8.1.1 + flex_color_scheme: ^8.2.0 flutter: sdk: flutter flutter_adaptive_scaffold: ^0.3.2 @@ -57,13 +57,18 @@ dependencies: ht_shared: git: url: https://github.com/headlines-toolkit/ht-shared.git + ht_ui_kit: + git: + url: https://github.com/headlines-toolkit/ht-ui-kit.git intl: ^0.20.2 js_interop: ^0.0.1 + logging: ^1.3.0 meta: ^1.16.0 share_plus: ^11.0.0 stream_transform: ^2.1.1 timeago: ^3.7.1 url_launcher: ^6.3.1 + uuid: ^4.4.0 dev_dependencies: bloc_test: ^10.0.0