diff --git a/analysis_options.yaml b/analysis_options.yaml index 6d3ea758..2f5f0fb0 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -2,6 +2,7 @@ analyzer: errors: avoid_catches_without_on_clauses: ignore avoid_print: ignore + avoid_redundant_argument_values: ignore document_ignores: ignore flutter_style_todos: ignore lines_longer_than_80_chars: ignore diff --git a/lib/account/bloc/account_bloc.dart b/lib/account/bloc/account_bloc.dart index 6de21ffb..50951269 100644 --- a/lib/account/bloc/account_bloc.dart +++ b/lib/account/bloc/account_bloc.dart @@ -69,27 +69,36 @@ class AccountBloc extends Bloc { AccountLoadContentPreferencesRequested event, Emitter emit, ) async { - emit(state.copyWith(status: AccountStatus.loading)); + emit(state.copyWith(status: AccountStatus.loading)); // Indicate loading try { final preferences = await _userContentPreferencesRepository.read( id: event.userId, - userId: event.userId, // Preferences are user-scoped + userId: event.userId, ); emit( state.copyWith(status: AccountStatus.success, preferences: preferences), ); - } on HtHttpException catch (e) { + } on NotFoundException { // Specifically handle NotFound + emit( + state.copyWith( + status: AccountStatus.success, // It's a success, just no data + preferences: UserContentPreferences(id: event.userId), // Provide default/empty + ), + ); + } on HtHttpException catch (e) { // Handle other HTTP errors emit( state.copyWith( status: AccountStatus.failure, errorMessage: 'Failed to load preferences: ${e.message}', + preferences: UserContentPreferences(id: event.userId), // Provide default ), ); - } catch (e) { + } catch (e) { // Catch-all for other unexpected errors emit( state.copyWith( status: AccountStatus.failure, errorMessage: 'An unexpected error occurred: $e', + preferences: UserContentPreferences(id: event.userId), // Provide default ), ); } @@ -108,15 +117,19 @@ class AccountBloc extends Bloc { ); return; } + print('[AccountBloc._persistPreferences] Attempting to persist preferences for user ${state.user!.id}'); + print('[AccountBloc._persistPreferences] Preferences to save: ${preferences.toJson()}'); try { await _userContentPreferencesRepository.update( id: state.user!.id, // ID of the preferences object is the user's ID item: preferences, userId: state.user!.id, ); + print('[AccountBloc._persistPreferences] Successfully persisted preferences for user ${state.user!.id}'); // Optimistic update already done, emit success if needed for UI feedback // emit(state.copyWith(status: AccountStatus.success)); } on HtHttpException catch (e) { + print('[AccountBloc._persistPreferences] HtHttpException while persisting: ${e.message}'); emit( state.copyWith( status: AccountStatus.failure, @@ -124,6 +137,7 @@ class AccountBloc extends Bloc { ), ); } catch (e) { + print('[AccountBloc._persistPreferences] Unknown error while persisting: $e'); emit( state.copyWith( status: AccountStatus.failure, diff --git a/lib/account/bloc/available_countries_bloc.dart b/lib/account/bloc/available_countries_bloc.dart new file mode 100644 index 00000000..7eea5644 --- /dev/null +++ b/lib/account/bloc/available_countries_bloc.dart @@ -0,0 +1,56 @@ +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, HtHttpException; + +part 'available_countries_event.dart'; +part 'available_countries_state.dart'; + +class AvailableCountriesBloc + extends Bloc { + AvailableCountriesBloc({ + required HtDataRepository countriesRepository, + }) : _countriesRepository = countriesRepository, + super(const AvailableCountriesState()) { + on(_onFetchAvailableCountries); + } + + final HtDataRepository _countriesRepository; + + Future _onFetchAvailableCountries( + FetchAvailableCountries event, + Emitter emit, + ) async { + if (state.status == AvailableCountriesStatus.loading || + state.status == AvailableCountriesStatus.success) { + return; + } + emit(state.copyWith(status: AvailableCountriesStatus.loading)); + try { + final response = await _countriesRepository.readAll(); + emit( + state.copyWith( + status: AvailableCountriesStatus.success, + availableCountries: response.items, + clearError: true, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: AvailableCountriesStatus.failure, + error: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: AvailableCountriesStatus.failure, + error: 'An unexpected error occurred while fetching countries.', + ), + ); + } + } +} diff --git a/lib/account/bloc/available_countries_event.dart b/lib/account/bloc/available_countries_event.dart new file mode 100644 index 00000000..ecad40bd --- /dev/null +++ b/lib/account/bloc/available_countries_event.dart @@ -0,0 +1,12 @@ +part of 'available_countries_bloc.dart'; + +abstract class AvailableCountriesEvent extends Equatable { + const AvailableCountriesEvent(); + + @override + List get props => []; +} + +class FetchAvailableCountries extends AvailableCountriesEvent { + const FetchAvailableCountries(); +} diff --git a/lib/account/bloc/available_countries_state.dart b/lib/account/bloc/available_countries_state.dart new file mode 100644 index 00000000..ca58400e --- /dev/null +++ b/lib/account/bloc/available_countries_state.dart @@ -0,0 +1,47 @@ +part of 'available_countries_bloc.dart'; + +enum AvailableCountriesStatus { initial, loading, success, failure } + +class AvailableCountriesState extends Equatable { + const AvailableCountriesState({ + this.status = AvailableCountriesStatus.initial, + this.availableCountries = const [], + this.error, + // Properties for pagination if added later + // this.hasMore = true, + // this.cursor, + }); + + final AvailableCountriesStatus status; + final List availableCountries; + final String? error; + // final bool hasMore; + // final String? cursor; + + AvailableCountriesState copyWith({ + AvailableCountriesStatus? status, + List? availableCountries, + String? error, + bool clearError = false, + // bool? hasMore, + // String? cursor, + // bool clearCursor = false, + }) { + return AvailableCountriesState( + status: status ?? this.status, + availableCountries: availableCountries ?? this.availableCountries, + error: clearError ? null : error ?? this.error, + // hasMore: hasMore ?? this.hasMore, + // cursor: clearCursor ? null : (cursor ?? this.cursor), + ); + } + + @override + List get props => [ + status, + availableCountries, + error, + // hasMore, // Add if pagination is implemented + // cursor, // Add if pagination is implemented + ]; +} diff --git a/lib/account/bloc/available_sources_bloc.dart b/lib/account/bloc/available_sources_bloc.dart new file mode 100644 index 00000000..884b4896 --- /dev/null +++ b/lib/account/bloc/available_sources_bloc.dart @@ -0,0 +1,66 @@ +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 HtHttpException, Source; + +part 'available_sources_event.dart'; +part 'available_sources_state.dart'; + +class AvailableSourcesBloc + extends Bloc { + AvailableSourcesBloc({ + required HtDataRepository sourcesRepository, + }) : _sourcesRepository = sourcesRepository, + super(const AvailableSourcesState()) { + on(_onFetchAvailableSources); + } + + final HtDataRepository _sourcesRepository; + // Consider adding a limit if the number of sources can be very large. + // static const _sourcesLimit = 50; + + Future _onFetchAvailableSources( + FetchAvailableSources event, + Emitter emit, + ) async { + if (state.status == AvailableSourcesStatus.loading || + state.status == AvailableSourcesStatus.success) { + // Avoid re-fetching if already loading or loaded, + // unless a refresh mechanism is added. + return; + } + emit(state.copyWith(status: AvailableSourcesStatus.loading)); + try { + // Assuming readAll without parameters fetches all items. + // Add pagination if necessary for very large datasets. + final response = await _sourcesRepository.readAll( + // limit: _sourcesLimit, // Uncomment if pagination is needed + ); + emit( + state.copyWith( + status: AvailableSourcesStatus.success, + availableSources: response.items, + // hasMore: response.hasMore, // Uncomment if pagination is needed + // cursor: response.cursor, // Uncomment if pagination is needed + clearError: true, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: AvailableSourcesStatus.failure, + error: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: AvailableSourcesStatus.failure, + error: 'An unexpected error occurred while fetching sources.', + ), + ); + } + } +} diff --git a/lib/account/bloc/available_sources_event.dart b/lib/account/bloc/available_sources_event.dart new file mode 100644 index 00000000..1dfd4339 --- /dev/null +++ b/lib/account/bloc/available_sources_event.dart @@ -0,0 +1,12 @@ +part of 'available_sources_bloc.dart'; + +abstract class AvailableSourcesEvent extends Equatable { + const AvailableSourcesEvent(); + + @override + List get props => []; +} + +class FetchAvailableSources extends AvailableSourcesEvent { + const FetchAvailableSources(); +} diff --git a/lib/account/bloc/available_sources_state.dart b/lib/account/bloc/available_sources_state.dart new file mode 100644 index 00000000..5398ce68 --- /dev/null +++ b/lib/account/bloc/available_sources_state.dart @@ -0,0 +1,47 @@ +part of 'available_sources_bloc.dart'; + +enum AvailableSourcesStatus { initial, loading, success, failure } + +class AvailableSourcesState extends Equatable { + const AvailableSourcesState({ + this.status = AvailableSourcesStatus.initial, + this.availableSources = const [], + this.error, + // Properties for pagination if added later + // this.hasMore = true, + // this.cursor, + }); + + final AvailableSourcesStatus status; + final List availableSources; + final String? error; + // final bool hasMore; + // final String? cursor; + + AvailableSourcesState copyWith({ + AvailableSourcesStatus? status, + List? availableSources, + String? error, + bool clearError = false, + // bool? hasMore, + // String? cursor, + // bool clearCursor = false, + }) { + return AvailableSourcesState( + status: status ?? this.status, + availableSources: availableSources ?? this.availableSources, + error: clearError ? null : error ?? this.error, + // hasMore: hasMore ?? this.hasMore, + // cursor: clearCursor ? null : (cursor ?? this.cursor), + ); + } + + @override + List get props => [ + status, + availableSources, + error, + // hasMore, // Add if pagination is implemented + // cursor, // Add if pagination is implemented + ]; +} diff --git a/lib/account/view/account_page.dart b/lib/account/view/account_page.dart index 24c46179..2b35771d 100644 --- a/lib/account/view/account_page.dart +++ b/lib/account/view/account_page.dart @@ -44,7 +44,7 @@ class AccountPage extends StatelessWidget { title: Text(l10n.accountContentPreferencesTile), trailing: const Icon(Icons.chevron_right), onTap: () { - context.goNamed(Routes.accountContentPreferencesName); + context.goNamed(Routes.manageFollowedItemsName); // Updated route }, ), const Divider(), // Divider after Content Preferences diff --git a/lib/account/view/content_preferences_page.dart b/lib/account/view/content_preferences_page.dart deleted file mode 100644 index 800e80f8..00000000 --- a/lib/account/view/content_preferences_page.dart +++ /dev/null @@ -1,293 +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/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'; - -/// {@template content_preferences_page} -/// Page for managing user's content preferences, including followed -/// categories, sources, and countries. -/// {@endtemplate} -class ContentPreferencesPage extends StatelessWidget { - /// {@macro content_preferences_page} - const ContentPreferencesPage({super.key}); - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - - return DefaultTabController( - length: 3, // Categories, Sources, Countries - child: Scaffold( - appBar: AppBar( - title: Text( - l10n.accountContentPreferencesTile, - ), // Reusing existing key - bottom: TabBar( - tabs: [ - Tab(text: l10n.headlinesFeedFilterCategoryLabel), // Reusing - Tab(text: l10n.headlinesFeedFilterSourceLabel), // Reusing - Tab(text: l10n.headlinesFeedFilterEventCountryLabel), // Reusing - ], - ), - ), - body: BlocBuilder( - builder: (context, state) { - if (state.status == AccountStatus.loading && - state.preferences == null) { - return const Center(child: CircularProgressIndicator()); - } - - if (state.status == AccountStatus.failure && - state.preferences == null) { - return Center( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.paddingLarge), - child: Text( - state.errorMessage ?? l10n.unknownError, - textAlign: TextAlign.center, - ), - ), - ); - } - - // Preferences might be null initially or if loading failed but user existed - final preferences = - state.preferences ?? const UserContentPreferences(id: ''); - - return TabBarView( - children: [ - _buildCategoriesView( - context, - preferences.followedCategories, - l10n, - ), - _buildSourcesView(context, preferences.followedSources, l10n), - _buildCountriesView( - context, - preferences.followedCountries, - l10n, - ), - ], - ); - }, - ), - ), - ); - } - - Widget _buildCategoriesView( - BuildContext context, - List followedCategories, - AppLocalizations l10n, - ) { - if (followedCategories.isEmpty) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(l10n.categoryFilterEmptyHeadline), // Reusing key - const SizedBox(height: AppSpacing.md), - ElevatedButton.icon( - icon: const Icon(Icons.add_circle_outline), - label: Text(l10n.headlinesFeedFilterCategoryLabel), // "Category" - onPressed: () { - context.goNamed(Routes.feedFilterCategoriesName); - }, - ), - ], - ); - } - - return Column( - children: [ - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(AppSpacing.paddingMedium), - itemCount: followedCategories.length, - itemBuilder: (context, index) { - final category = followedCategories[index]; - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - child: ListTile( - title: Text(category.name), - trailing: IconButton( - icon: const Icon( - Icons.remove_circle_outline, - color: Colors.red, - ), - tooltip: 'Unfollow ${category.name}', // Consider l10n - onPressed: () { - context.read().add( - AccountFollowCategoryToggled(category: category), - ); - }, - ), - ), - ); - }, - ), - ), - Padding( - padding: const EdgeInsets.all(AppSpacing.paddingMedium), - child: ElevatedButton.icon( - icon: const Icon(Icons.edit_outlined), - label: Text( - 'Manage ${l10n.headlinesFeedFilterCategoryLabel}', - ), // "Manage Category" - onPressed: () { - context.goNamed(Routes.feedFilterCategoriesName); - }, - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 48), - ), - ), - ), - ], - ); - } - - Widget _buildSourcesView( - BuildContext context, - List followedSources, - AppLocalizations l10n, - ) { - if (followedSources.isEmpty) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(l10n.sourceFilterEmptyHeadline), // Reusing key - const SizedBox(height: AppSpacing.md), - ElevatedButton.icon( - icon: const Icon(Icons.add_circle_outline), - label: Text(l10n.headlinesFeedFilterSourceLabel), // "Source" - onPressed: () { - context.goNamed(Routes.feedFilterSourcesName); - }, - ), - ], - ); - } - - return Column( - children: [ - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(AppSpacing.paddingMedium), - itemCount: followedSources.length, - itemBuilder: (context, index) { - final source = followedSources[index]; - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - child: ListTile( - title: Text(source.name), - // Consider adding source.iconUrl if available and desired - trailing: IconButton( - icon: const Icon( - Icons.remove_circle_outline, - color: Colors.red, - ), - tooltip: 'Unfollow ${source.name}', // Consider l10n - onPressed: () { - context.read().add( - AccountFollowSourceToggled(source: source), - ); - }, - ), - ), - ); - }, - ), - ), - Padding( - padding: const EdgeInsets.all(AppSpacing.paddingMedium), - child: ElevatedButton.icon( - icon: const Icon(Icons.edit_outlined), - label: Text( - 'Manage ${l10n.headlinesFeedFilterSourceLabel}', - ), // "Manage Source" - onPressed: () { - context.goNamed(Routes.feedFilterSourcesName); - }, - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 48), - ), - ), - ), - ], - ); - } - - Widget _buildCountriesView( - BuildContext context, - List followedCountries, - AppLocalizations l10n, - ) { - if (followedCountries.isEmpty) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(l10n.countryFilterEmptyHeadline), // Reusing key - const SizedBox(height: AppSpacing.md), - ElevatedButton.icon( - icon: const Icon(Icons.add_circle_outline), - label: Text(l10n.headlinesFeedFilterEventCountryLabel), // "Country" - onPressed: - null, // TODO: Implement new navigation/management for followed countries - ), - ], - ); - } - - return Column( - children: [ - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(AppSpacing.paddingMedium), - itemCount: followedCountries.length, - itemBuilder: (context, index) { - final country = followedCountries[index]; - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - child: ListTile( - // leading: country.flagUrl != null ? Image.network(country.flagUrl!, width: 36, height: 24, fit: BoxFit.cover) : null, // Optional: Display flag - title: Text(country.name), - trailing: IconButton( - icon: const Icon( - Icons.remove_circle_outline, - color: Colors.red, - ), - tooltip: 'Unfollow ${country.name}', // Consider l10n - onPressed: () { - context.read().add( - AccountFollowCountryToggled(country: country), - ); - }, - ), - ), - ); - }, - ), - ), - Padding( - padding: const EdgeInsets.all(AppSpacing.paddingMedium), - child: ElevatedButton.icon( - icon: const Icon(Icons.edit_outlined), - label: Text( - 'Manage ${l10n.headlinesFeedFilterEventCountryLabel}', - ), // "Manage Country" - // onPressed: () { - // context.goNamed(Routes.feedFilterCountriesName); - // }, // TODO: Implement new navigation/management for followed countries - onPressed: null, // Temporarily disable until new flow is defined - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 48), - ), - ), - ), - ], - ); - } -} diff --git a/lib/account/view/manage_followed_items/categories/add_category_to_follow_page.dart b/lib/account/view/manage_followed_items/categories/add_category_to_follow_page.dart new file mode 100644 index 00000000..f3db6fd3 --- /dev/null +++ b/lib/account/view/manage_followed_items/categories/add_category_to_follow_page.dart @@ -0,0 +1,118 @@ +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/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'; + +/// {@template add_category_to_follow_page} +/// A page that allows users to browse and select categories to follow. +/// {@endtemplate} +class AddCategoryToFollowPage extends StatelessWidget { + /// {@macro add_category_to_follow_page} + const AddCategoryToFollowPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BlocProvider( + create: (context) => CategoriesFilterBloc( + categoriesRepository: context.read>(), + )..add(CategoriesFilterRequested()), + child: Scaffold( + appBar: AppBar( + title: Text(l10n.addCategoriesPageTitle), + ), + body: BlocBuilder( + builder: (context, categoriesState) { + if (categoriesState.status == CategoriesFilterStatus.loading) { + return const Center(child: CircularProgressIndicator()); + } + if (categoriesState.status == CategoriesFilterStatus.failure) { + var errorMessage = l10n.categoryFilterError; + if (categoriesState.error is HtHttpException) { + errorMessage = (categoriesState.error! as HtHttpException).message; + } else if (categoriesState.error != null) { + errorMessage = categoriesState.error.toString(); + } + return FailureStateWidget( + message: errorMessage, + onRetry: () => context + .read() + .add(CategoriesFilterRequested()), + ); + } + if (categoriesState.categories.isEmpty) { + return FailureStateWidget( + message: l10n.categoryFilterEmptyHeadline, + ); + } + + return BlocBuilder( + buildWhen: (previous, current) => + previous.preferences?.followedCategories != current.preferences?.followedCategories || + previous.status != current.status, + builder: (context, accountState) { + final followedCategories = + accountState.preferences?.followedCategories ?? []; + + return ListView.builder( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: categoriesState.categories.length, + itemBuilder: (context, index) { + final category = categoriesState.categories[index]; + final isFollowed = followedCategories + .any((fc) => fc.id == category.id); + + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ListTile( + leading: category.iconUrl != null && + Uri.tryParse(category.iconUrl!)?.isAbsolute == + true + ? SizedBox( + width: 36, + height: 36, + child: Image.network( + category.iconUrl!, + fit: BoxFit.contain, + errorBuilder: + (context, error, stackTrace) => + const Icon(Icons.category_outlined), + ), + ) + : const Icon(Icons.category_outlined), + title: Text(category.name), + trailing: IconButton( + icon: isFollowed + ? Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + ) + : const Icon(Icons.add_circle_outline), + tooltip: isFollowed + ? l10n.unfollowCategoryTooltip(category.name) + : l10n.followCategoryTooltip(category.name), + onPressed: () { + context.read().add( + AccountFollowCategoryToggled( + category: category, + ), + ); + }, + ), + ), + ); + }, + ); + }, + ); + }, + ), + ), + ); + } +} 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 new file mode 100644 index 00000000..c57a0284 --- /dev/null +++ b/lib/account/view/manage_followed_items/categories/followed_categories_list_page.dart @@ -0,0 +1,131 @@ +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/l10n/l10n.dart'; +import 'package:ht_main/router/routes.dart'; +import 'package:ht_main/shared/constants/app_spacing.dart'; +import 'package:ht_main/shared/widgets/widgets.dart'; + +/// {@template followed_categories_list_page} +/// Displays a list of categories the user is currently following. +/// Allows unfollowing and navigating to add more categories. +/// {@endtemplate} +class FollowedCategoriesListPage extends StatelessWidget { + /// {@macro followed_categories_list_page} + const FollowedCategoriesListPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.followedCategoriesPageTitle), + actions: [ + IconButton( + icon: const Icon(Icons.add_circle_outline), + tooltip: l10n.addCategoriesTooltip, + onPressed: () { + context.goNamed(Routes.addCategoryToFollowName); + }, + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + if (state.status == AccountStatus.initial || + (state.status == AccountStatus.loading && + state.preferences == null)) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.status == AccountStatus.failure && + state.preferences == null) { + return FailureStateWidget( + message: state.errorMessage ?? l10n.unknownError, + onRetry: () { + if (state.user?.id != null) { + context.read().add( + AccountLoadContentPreferencesRequested( + userId: state.user!.id, + ), + ); + } + }, + ); + } + + final followedCategories = state.preferences?.followedCategories; + + if (followedCategories == null || followedCategories.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.category_outlined, size: 48), + const SizedBox(height: AppSpacing.md), + Text( + l10n.noFollowedCategoriesMessage, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + ElevatedButton.icon( + icon: const Icon(Icons.add_circle_outline), + label: Text(l10n.addCategoriesButtonLabel), + onPressed: () { + context.goNamed(Routes.addCategoryToFollowName); + }, + ), + ], + ), + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: followedCategories.length, + itemBuilder: (context, index) { + final category = followedCategories[index]; + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ListTile( + leading: category.iconUrl != null && + Uri.tryParse(category.iconUrl!)?.isAbsolute == true + ? SizedBox( + width: 36, + height: 36, + child: Image.network( + category.iconUrl!, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.category_outlined), + ), + ) + : const Icon(Icons.category_outlined), + title: Text(category.name), + trailing: IconButton( + icon: Icon( + Icons.remove_circle_outline, + color: Theme.of(context).colorScheme.error, + ), + tooltip: l10n.unfollowCategoryTooltip(category.name), + onPressed: () { + context.read().add( + AccountFollowCategoryToggled(category: category), + ); + }, + ), + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/account/view/manage_followed_items/countries/add_country_to_follow_page.dart b/lib/account/view/manage_followed_items/countries/add_country_to_follow_page.dart new file mode 100644 index 00000000..d06fa7ad --- /dev/null +++ b/lib/account/view/manage_followed_items/countries/add_country_to_follow_page.dart @@ -0,0 +1,141 @@ +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/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'; + +/// {@template add_country_to_follow_page} +/// A page that allows users to browse and select countries to follow. +/// {@endtemplate} +class AddCountryToFollowPage extends StatefulWidget { + /// {@macro add_country_to_follow_page} + const AddCountryToFollowPage({super.key}); + + @override + State createState() => _AddCountryToFollowPageState(); +} + +class _AddCountryToFollowPageState extends State { + List _allCountries = []; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _fetchCountries(); + } + + Future _fetchCountries() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + try { + final countryRepository = context.read>(); + final paginatedResponse = await countryRepository.readAll(); + setState(() { + _allCountries = paginatedResponse.items; + _isLoading = false; + }); + } on HtHttpException catch (e) { + setState(() { + _isLoading = false; + _errorMessage = e.message; + }); + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = context.l10n.unknownError; + }); + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.addCountriesPageTitle), + ), + body: Builder( + builder: (context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_errorMessage != null) { + return FailureStateWidget( + message: _errorMessage!, + onRetry: _fetchCountries, + ); + } + if (_allCountries.isEmpty) { + return FailureStateWidget( + message: l10n.countryFilterEmptyHeadline, + ); + } + + return BlocBuilder( + buildWhen: (previous, current) => + previous.preferences?.followedCountries != current.preferences?.followedCountries || + previous.status != current.status, + builder: (context, accountState) { + final followedCountries = + accountState.preferences?.followedCountries ?? []; + + return ListView.builder( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: _allCountries.length, + itemBuilder: (context, index) { + final country = _allCountries[index]; + final isFollowed = + followedCountries.any((fc) => fc.id == country.id); + + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ListTile( + leading: country.flagUrl.isNotEmpty && + Uri.tryParse(country.flagUrl)?.isAbsolute == true + ? SizedBox( + width: 36, + height: 24, + child: Image.network( + country.flagUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.public_outlined), + ), + ) + : const Icon(Icons.public_outlined), + title: Text(country.name), + trailing: IconButton( + icon: isFollowed + ? Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + ) + : const Icon(Icons.add_circle_outline), + tooltip: isFollowed + ? l10n.unfollowCountryTooltip(country.name) + : l10n.followCountryTooltip(country.name), + onPressed: () { + context.read().add( + AccountFollowCountryToggled(country: country), + ); + }, + ), + ), + ); + }, + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart b/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart new file mode 100644 index 00000000..bc8b4e91 --- /dev/null +++ b/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart @@ -0,0 +1,131 @@ +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/l10n/l10n.dart'; +import 'package:ht_main/router/routes.dart'; +import 'package:ht_main/shared/constants/app_spacing.dart'; +import 'package:ht_main/shared/widgets/widgets.dart'; + +/// {@template followed_countries_list_page} +/// Displays a list of countries the user is currently following. +/// Allows unfollowing and navigating to add more countries. +/// {@endtemplate} +class FollowedCountriesListPage extends StatelessWidget { + /// {@macro followed_countries_list_page} + const FollowedCountriesListPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.followedCountriesPageTitle), + actions: [ + IconButton( + icon: const Icon(Icons.add_circle_outline), + tooltip: l10n.addCountriesTooltip, + onPressed: () { + context.goNamed(Routes.addCountryToFollowName); + }, + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + if (state.status == AccountStatus.initial || + (state.status == AccountStatus.loading && + state.preferences == null)) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.status == AccountStatus.failure && + state.preferences == null) { + return FailureStateWidget( + message: state.errorMessage ?? l10n.unknownError, + onRetry: () { + if (state.user?.id != null) { + context.read().add( + AccountLoadContentPreferencesRequested( + userId: state.user!.id, + ), + ); + } + }, + ); + } + + final followedCountries = state.preferences?.followedCountries; + + if (followedCountries == null || followedCountries.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.public_outlined, size: 48), + const SizedBox(height: AppSpacing.md), + Text( + l10n.noFollowedCountriesMessage, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + ElevatedButton.icon( + icon: const Icon(Icons.add_circle_outline), + label: Text(l10n.addCountriesButtonLabel), + onPressed: () { + context.goNamed(Routes.addCountryToFollowName); + }, + ), + ], + ), + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: followedCountries.length, + itemBuilder: (context, index) { + final country = followedCountries[index]; + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ListTile( + leading: country.flagUrl.isNotEmpty && + Uri.tryParse(country.flagUrl)?.isAbsolute == true + ? SizedBox( + width: 36, + height: 24, + child: Image.network( + country.flagUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.public_outlined), + ), + ) + : const Icon(Icons.public_outlined), + title: Text(country.name), + trailing: IconButton( + icon: Icon( + Icons.remove_circle_outline, + color: Theme.of(context).colorScheme.error, + ), + tooltip: l10n.unfollowCountryTooltip(country.name), + onPressed: () { + context.read().add( + AccountFollowCountryToggled(country: country), + ); + }, + ), + ), + ); + }, + ); + }, + ), + ); + } +} 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 new file mode 100644 index 00000000..95b7557a --- /dev/null +++ b/lib/account/view/manage_followed_items/manage_followed_items_page.dart @@ -0,0 +1,57 @@ +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'; + +/// {@template manage_followed_items_page} +/// Page for navigating to lists of followed content types like +/// categories, sources, and countries. +/// {@endtemplate} +class ManageFollowedItemsPage extends StatelessWidget { + /// {@macro manage_followed_items_page} + const ManageFollowedItemsPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.accountContentPreferencesTile), // "Content Preferences" + ), + body: ListView( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + children: [ + ListTile( + leading: const Icon(Icons.category_outlined), + title: Text(l10n.headlinesFeedFilterCategoryLabel), // "Categories" + trailing: const Icon(Icons.chevron_right), + onTap: () { + context.goNamed(Routes.followedCategoriesListName); + }, + ), + const Divider(indent: AppSpacing.lg, endIndent: AppSpacing.lg), + ListTile( + leading: const Icon(Icons.source_outlined), + title: Text(l10n.headlinesFeedFilterSourceLabel), // "Sources" + trailing: const Icon(Icons.chevron_right), + onTap: () { + context.goNamed(Routes.followedSourcesListName); + }, + ), + const Divider(indent: AppSpacing.lg, endIndent: AppSpacing.lg), + ListTile( + leading: const Icon(Icons.public_outlined), + title: Text(l10n.headlinesFeedFilterEventCountryLabel), // "Countries" + trailing: const Icon(Icons.chevron_right), + onTap: () { + context.goNamed(Routes.followedCountriesListName); + }, + ), + const Divider(indent: AppSpacing.lg, endIndent: AppSpacing.lg), + ], + ), + ); + } +} 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 new file mode 100644 index 00000000..8b40c73f --- /dev/null +++ b/lib/account/view/manage_followed_items/sources/add_source_to_follow_page.dart @@ -0,0 +1,96 @@ +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/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'; + +/// {@template add_source_to_follow_page} +/// A page that allows users to browse and select sources to follow. +/// {@endtemplate} +class AddSourceToFollowPage extends StatelessWidget { + /// {@macro add_source_to_follow_page} + const AddSourceToFollowPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BlocProvider( + create: (context) => SourcesFilterBloc( + sourcesRepository: context.read>(), + countriesRepository: context.read>(), + )..add(const LoadSourceFilterData()), + child: Scaffold( + appBar: AppBar( + title: Text(l10n.addSourcesPageTitle), + ), + body: BlocBuilder( + builder: (context, sourcesState) { + if (sourcesState.dataLoadingStatus == SourceFilterDataLoadingStatus.loading || sourcesState.dataLoadingStatus == SourceFilterDataLoadingStatus.initial) { + return const Center(child: CircularProgressIndicator()); + } + if (sourcesState.dataLoadingStatus == SourceFilterDataLoadingStatus.failure) { + return FailureStateWidget( + message: sourcesState.errorMessage ?? l10n.sourceFilterError, + onRetry: () => context + .read() + .add(const LoadSourceFilterData()), + ); + } + if (sourcesState.allAvailableSources.isEmpty) { + return FailureStateWidget( + message: l10n.sourceFilterEmptyHeadline, + ); + } + + return BlocBuilder( + buildWhen: (previous, current) => + previous.preferences?.followedSources != current.preferences?.followedSources || + previous.status != current.status, + builder: (context, accountState) { + final followedSources = + accountState.preferences?.followedSources ?? []; + + return ListView.builder( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: sourcesState.allAvailableSources.length, + itemBuilder: (context, index) { + final source = sourcesState.allAvailableSources[index]; + final isFollowed = + followedSources.any((fs) => fs.id == source.id); + + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ListTile( + title: Text(source.name), + trailing: IconButton( + icon: isFollowed + ? Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + ) + : const Icon(Icons.add_circle_outline), + tooltip: isFollowed + ? l10n.unfollowSourceTooltip(source.name) + : l10n.followSourceTooltip(source.name), + onPressed: () { + context.read().add( + AccountFollowSourceToggled(source: source), + ); + }, + ), + ), + ); + }, + ); + }, + ); + }, + ), + ), + ); + } +} 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 new file mode 100644 index 00000000..2f3b7fc8 --- /dev/null +++ b/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart @@ -0,0 +1,118 @@ +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/l10n/l10n.dart'; +import 'package:ht_main/router/routes.dart'; +import 'package:ht_main/shared/constants/app_spacing.dart'; +import 'package:ht_main/shared/widgets/widgets.dart'; + +/// {@template followed_sources_list_page} +/// Displays a list of sources the user is currently following. +/// Allows unfollowing and navigating to add more sources. +/// {@endtemplate} +class FollowedSourcesListPage extends StatelessWidget { + /// {@macro followed_sources_list_page} + const FollowedSourcesListPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.followedSourcesPageTitle), + actions: [ + IconButton( + icon: const Icon(Icons.add_circle_outline), + tooltip: l10n.addSourcesTooltip, + onPressed: () { + context.goNamed(Routes.addSourceToFollowName); + }, + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + if (state.status == AccountStatus.initial || + (state.status == AccountStatus.loading && + state.preferences == null)) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.status == AccountStatus.failure && + state.preferences == null) { + return FailureStateWidget( + message: state.errorMessage ?? l10n.unknownError, + onRetry: () { + if (state.user?.id != null) { + context.read().add( + AccountLoadContentPreferencesRequested( + userId: state.user!.id, + ), + ); + } + }, + ); + } + + final followedSources = state.preferences?.followedSources; + + if (followedSources == null || followedSources.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.source_outlined, size: 48), + const SizedBox(height: AppSpacing.md), + Text( + l10n.noFollowedSourcesMessage, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + ElevatedButton.icon( + icon: const Icon(Icons.add_circle_outline), + label: Text(l10n.addSourcesButtonLabel), + onPressed: () { + context.goNamed(Routes.addSourceToFollowName); + }, + ), + ], + ), + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: followedSources.length, + itemBuilder: (context, index) { + final source = followedSources[index]; + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ListTile( + title: Text(source.name), + trailing: IconButton( + icon: Icon( + Icons.remove_circle_outline, + color: Theme.of(context).colorScheme.error, + ), + tooltip: l10n.unfollowSourceTooltip(source.name), + onPressed: () { + context.read().add( + AccountFollowSourceToggled(source: source), + ); + }, + ), + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 46fa356b..4176780d 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -631,5 +631,133 @@ "searchPageInitialSubheadline": "اختر نوعًا وأدخل كلمات رئيسية للبدء.", "@searchPageInitialSubheadline": { "description": "Generic subheadline for the initial state of the search page" + }, + "followedCategoriesPageTitle": "الفئات المتابَعة", + "@followedCategoriesPageTitle": { + "description": "عنوان صفحة عرض الفئات المتابعة" + }, + "addCategoriesTooltip": "إضافة فئات", + "@addCategoriesTooltip": { + "description": "تلميح زر إضافة فئات جديدة للمتابعة" + }, + "noFollowedCategoriesMessage": "أنت لا تتابع أي فئات حتى الآن.", + "@noFollowedCategoriesMessage": { + "description": "رسالة تُعرض عندما لا يكون المستخدم متابعًا لأي فئات" + }, + "addCategoriesButtonLabel": "البحث عن فئات لمتابعتها", + "@addCategoriesButtonLabel": { + "description": "نص الزر الذي ينقل المستخدم لصفحة إضافة الفئات" + }, + "unfollowCategoryTooltip": "إلغاء متابعة {categoryName}", + "@unfollowCategoryTooltip": { + "description": "تلميح زر إلغاء متابعة فئة معينة", + "placeholders": { + "categoryName": { + "type": "String", + "example": "تكنولوجيا" + } + } + }, + "followedSourcesPageTitle": "المصادر المتابَعة", + "@followedSourcesPageTitle": { + "description": "عنوان صفحة عرض المصادر المتابعة" + }, + "addSourcesTooltip": "إضافة مصادر", + "@addSourcesTooltip": { + "description": "تلميح زر إضافة مصادر جديدة للمتابعة" + }, + "noFollowedSourcesMessage": "أنت لا تتابع أي مصادر حتى الآن.", + "@noFollowedSourcesMessage": { + "description": "رسالة تُعرض عندما لا يكون المستخدم متابعًا لأي مصادر" + }, + "addSourcesButtonLabel": "البحث عن مصادر لمتابعتها", + "@addSourcesButtonLabel": { + "description": "نص الزر الذي ينقل المستخدم لصفحة إضافة المصادر" + }, + "unfollowSourceTooltip": "إلغاء متابعة {sourceName}", + "@unfollowSourceTooltip": { + "description": "تلميح زر إلغاء متابعة مصدر معين", + "placeholders": { + "sourceName": { + "type": "String", + "example": "بي بي سي نيوز" + } + } + }, + "followedCountriesPageTitle": "الدول المتابَعة", + "@followedCountriesPageTitle": { + "description": "عنوان صفحة عرض الدول المتابعة" + }, + "addCountriesTooltip": "إضافة دول", + "@addCountriesTooltip": { + "description": "تلميح زر إضافة دول جديدة للمتابعة" + }, + "noFollowedCountriesMessage": "أنت لا تتابع أي دول حتى الآن.", + "@noFollowedCountriesMessage": { + "description": "رسالة تُعرض عندما لا يكون المستخدم متابعًا لأي دول" + }, + "addCountriesButtonLabel": "البحث عن دول لمتابعتها", + "@addCountriesButtonLabel": { + "description": "نص الزر الذي ينقل المستخدم لصفحة إضافة الدول" + }, + "unfollowCountryTooltip": "إلغاء متابعة {countryName}", + "@unfollowCountryTooltip": { + "description": "تلميح زر إلغاء متابعة دولة معينة", + "placeholders": { + "countryName": { + "type": "String", + "example": "الولايات المتحدة" + } + } + }, + "addCategoriesPageTitle": "إضافة فئات للمتابعة", + "@addCategoriesPageTitle": { + "description": "عنوان الصفحة التي يمكن للمستخدمين من خلالها إضافة فئات لمتابعتها" + }, + "categoryFilterError": "تعذر تحميل الفئات. يرجى المحاولة مرة أخرى.", + "@categoryFilterError": { + "description": "رسالة خطأ عند فشل تحميل الفئات في صفحة التصفية/الإضافة" + }, + "followCategoryTooltip": "متابعة {categoryName}", + "@followCategoryTooltip": { + "description": "تلميح زر متابعة فئة معينة", + "placeholders": { + "categoryName": { + "type": "String", + "example": "علوم" + } + } + }, + "addSourcesPageTitle": "إضافة مصادر للمتابعة", + "@addSourcesPageTitle": { + "description": "عنوان الصفحة التي يمكن للمستخدمين من خلالها إضافة مصادر لمتابعتها" + }, + "sourceFilterError": "تعذر تحميل المصادر. يرجى المحاولة مرة أخرى.", + "@sourceFilterError": { + "description": "رسالة خطأ عند فشل تحميل المصادر في صفحة التصفية/الإضافة" + }, + "followSourceTooltip": "متابعة {sourceName}", + "@followSourceTooltip": { + "description": "تلميح زر متابعة مصدر معين", + "placeholders": { + "sourceName": { + "type": "String", + "example": "أخبار تقنية" + } + } + }, + "addCountriesPageTitle": "إضافة دول للمتابعة", + "@addCountriesPageTitle": { + "description": "عنوان الصفحة التي يمكن للمستخدمين من خلالها إضافة دول لمتابعتها" + }, + "followCountryTooltip": "متابعة {countryName}", + "@followCountryTooltip": { + "description": "تلميح زر متابعة دولة معينة", + "placeholders": { + "countryName": { + "type": "String", + "example": "ألمانيا" + } + } } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index cae4c976..e8a94e78 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -631,5 +631,133 @@ "searchPageInitialSubheadline": "Select a type and enter keywords to begin.", "@searchPageInitialSubheadline": { "description": "Generic subheadline for the initial state of the search page" + }, + "followedCategoriesPageTitle": "Followed Categories", + "@followedCategoriesPageTitle": { + "description": "Title for the page listing followed categories" + }, + "addCategoriesTooltip": "Add Categories", + "@addCategoriesTooltip": { + "description": "Tooltip for the button to add new categories to follow" + }, + "noFollowedCategoriesMessage": "You are not following any categories yet.", + "@noFollowedCategoriesMessage": { + "description": "Message displayed when the user has no followed categories" + }, + "addCategoriesButtonLabel": "Find Categories to Follow", + "@addCategoriesButtonLabel": { + "description": "Label for the button that navigates to the page for adding categories" + }, + "unfollowCategoryTooltip": "Unfollow {categoryName}", + "@unfollowCategoryTooltip": { + "description": "Tooltip for the button to unfollow a specific category", + "placeholders": { + "categoryName": { + "type": "String", + "example": "Technology" + } + } + }, + "followedSourcesPageTitle": "Followed Sources", + "@followedSourcesPageTitle": { + "description": "Title for the page listing followed sources" + }, + "addSourcesTooltip": "Add Sources", + "@addSourcesTooltip": { + "description": "Tooltip for the button to add new sources to follow" + }, + "noFollowedSourcesMessage": "You are not following any sources yet.", + "@noFollowedSourcesMessage": { + "description": "Message displayed when the user has no followed sources" + }, + "addSourcesButtonLabel": "Find Sources to Follow", + "@addSourcesButtonLabel": { + "description": "Label for the button that navigates to the page for adding sources" + }, + "unfollowSourceTooltip": "Unfollow {sourceName}", + "@unfollowSourceTooltip": { + "description": "Tooltip for the button to unfollow a specific source", + "placeholders": { + "sourceName": { + "type": "String", + "example": "BBC News" + } + } + }, + "followedCountriesPageTitle": "Followed Countries", + "@followedCountriesPageTitle": { + "description": "Title for the page listing followed countries" + }, + "addCountriesTooltip": "Add Countries", + "@addCountriesTooltip": { + "description": "Tooltip for the button to add new countries to follow" + }, + "noFollowedCountriesMessage": "You are not following any countries yet.", + "@noFollowedCountriesMessage": { + "description": "Message displayed when the user has no followed countries" + }, + "addCountriesButtonLabel": "Find Countries to Follow", + "@addCountriesButtonLabel": { + "description": "Label for the button that navigates to the page for adding countries" + }, + "unfollowCountryTooltip": "Unfollow {countryName}", + "@unfollowCountryTooltip": { + "description": "Tooltip for the button to unfollow a specific country", + "placeholders": { + "countryName": { + "type": "String", + "example": "United States" + } + } + }, + "addCategoriesPageTitle": "Add Categories to Follow", + "@addCategoriesPageTitle": { + "description": "Title for the page where users can add categories to follow" + }, + "categoryFilterError": "Could not load categories. Please try again.", + "@categoryFilterError": { + "description": "Error message when categories fail to load on the filter/add page" + }, + "followCategoryTooltip": "Follow {categoryName}", + "@followCategoryTooltip": { + "description": "Tooltip for the button to follow a specific category", + "placeholders": { + "categoryName": { + "type": "String", + "example": "Science" + } + } + }, + "addSourcesPageTitle": "Add Sources to Follow", + "@addSourcesPageTitle": { + "description": "Title for the page where users can add sources to follow" + }, + "sourceFilterError": "Could not load sources. Please try again.", + "@sourceFilterError": { + "description": "Error message when sources fail to load on the filter/add page" + }, + "followSourceTooltip": "Follow {sourceName}", + "@followSourceTooltip": { + "description": "Tooltip for the button to follow a specific source", + "placeholders": { + "sourceName": { + "type": "String", + "example": "Tech News" + } + } + }, + "addCountriesPageTitle": "Add Countries to Follow", + "@addCountriesPageTitle": { + "description": "Title for the page where users can add countries to follow" + }, + "followCountryTooltip": "Follow {countryName}", + "@followCountryTooltip": { + "description": "Tooltip for the button to follow a specific country", + "placeholders": { + "countryName": { + "type": "String", + "example": "Germany" + } + } } } diff --git a/lib/router/router.dart b/lib/router/router.dart index 39099b66..ab4c0d4d 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -5,6 +5,13 @@ import 'package:ht_auth_repository/ht_auth_repository.dart'; // Auth Repository import 'package:ht_data_repository/ht_data_repository.dart'; // Generic Data Repository 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'; // New +import 'package:ht_main/account/view/manage_followed_items/countries/add_country_to_follow_page.dart'; // New +import 'package:ht_main/account/view/manage_followed_items/sources/add_source_to_follow_page.dart'; // New +import 'package:ht_main/account/view/manage_followed_items/categories/followed_categories_list_page.dart'; // New +import 'package:ht_main/account/view/manage_followed_items/countries/followed_countries_list_page.dart'; // New +import 'package:ht_main/account/view/manage_followed_items/sources/followed_sources_list_page.dart'; // New +import 'package:ht_main/account/view/manage_followed_items/manage_followed_items_page.dart'; // New import 'package:ht_main/app/bloc/app_bloc.dart'; import 'package:ht_main/app/view/app_shell.dart'; import 'package:ht_main/authentication/bloc/authentication_bloc.dart'; @@ -541,21 +548,57 @@ GoRouter createRouter({ ), // New routes for Account sub-pages GoRoute( - path: - Routes - .accountContentPreferences, // Relative path 'content-preferences' - name: Routes.accountContentPreferencesName, - builder: (context, state) { - // TODO(fulleni): Replace with actual ContentPreferencesPage - return const Placeholder( - child: Center(child: Text('CONTENT PREFERENCES PAGE')), - ); - }, + path: Routes.manageFollowedItems, // Updated path + name: Routes.manageFollowedItemsName, // Updated name + builder: (context, state) => + const ManageFollowedItemsPage(), // Use the new page + routes: [ + GoRoute( + path: Routes.followedCategoriesList, + name: Routes.followedCategoriesListName, + builder: (context, state) => + const FollowedCategoriesListPage(), + routes: [ + GoRoute( + path: Routes.addCategoryToFollow, + name: Routes.addCategoryToFollowName, + builder: (context, state) => + const AddCategoryToFollowPage(), + ), + ], + ), + GoRoute( + path: Routes.followedSourcesList, + name: Routes.followedSourcesListName, + builder: (context, state) => + const FollowedSourcesListPage(), + routes: [ + GoRoute( + path: Routes.addSourceToFollow, + name: Routes.addSourceToFollowName, + builder: (context, state) => + const AddSourceToFollowPage(), + ), + ], + ), + GoRoute( + path: Routes.followedCountriesList, + name: Routes.followedCountriesListName, + builder: (context, state) => + const FollowedCountriesListPage(), + routes: [ + GoRoute( + path: Routes.addCountryToFollow, + name: Routes.addCountryToFollowName, + builder: (context, state) => + const AddCountryToFollowPage(), + ), + ], + ), + ], ), GoRoute( - path: - Routes - .accountSavedHeadlines, // Relative path 'saved-headlines' + path: Routes.accountSavedHeadlines, name: Routes.accountSavedHeadlinesName, builder: (context, state) { // TODO(fulleni): Replace with actual SavedHeadlinesPage diff --git a/lib/router/routes.dart b/lib/router/routes.dart index 58744860..da4f74c3 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -66,8 +66,24 @@ abstract final class Routes { // static const settingsNotificationCategoriesName = 'settingsNotificationCategories'; // --- Account Sub-Routes (relative to /account) --- - static const accountContentPreferences = 'content-preferences'; - static const accountContentPreferencesName = 'accountContentPreferences'; + static const manageFollowedItems = 'manage-followed-items'; // Renamed + static const manageFollowedItemsName = 'manageFollowedItems'; // Renamed static const accountSavedHeadlines = 'saved-headlines'; static const accountSavedHeadlinesName = 'accountSavedHeadlines'; + + // --- 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 followedSourcesList = 'sources'; + static const followedSourcesListName = 'followedSourcesList'; + static const addSourceToFollow = 'add-source'; + static const addSourceToFollowName = 'addSourceToFollow'; + + static const followedCountriesList = 'countries'; + static const followedCountriesListName = 'followedCountriesList'; + static const addCountryToFollow = 'add-country'; + static const addCountryToFollowName = 'addCountryToFollow'; }