diff --git a/analysis_options.yaml b/analysis_options.yaml index 95dc1774..6d3ea758 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -3,6 +3,7 @@ analyzer: avoid_catches_without_on_clauses: ignore avoid_print: ignore document_ignores: ignore + flutter_style_todos: ignore lines_longer_than_80_chars: ignore use_if_null_to_convert_nulls_to_bools: ignore include: package:very_good_analysis/analysis_options.7.0.0.yaml diff --git a/lib/account/view/content_preferences_page.dart b/lib/account/view/content_preferences_page.dart index f752e227..800e80f8 100644 --- a/lib/account/view/content_preferences_page.dart +++ b/lib/account/view/content_preferences_page.dart @@ -234,9 +234,8 @@ class ContentPreferencesPage extends StatelessWidget { ElevatedButton.icon( icon: const Icon(Icons.add_circle_outline), label: Text(l10n.headlinesFeedFilterEventCountryLabel), // "Country" - onPressed: () { - context.goNamed(Routes.feedFilterCountriesName); - }, + onPressed: + null, // TODO: Implement new navigation/management for followed countries ), ], ); @@ -279,9 +278,10 @@ class ContentPreferencesPage extends StatelessWidget { label: Text( 'Manage ${l10n.headlinesFeedFilterEventCountryLabel}', ), // "Manage Country" - onPressed: () { - context.goNamed(Routes.feedFilterCountriesName); - }, + // 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/headlines-feed/bloc/headlines_feed_bloc.dart b/lib/headlines-feed/bloc/headlines_feed_bloc.dart index aba0ce8d..d07c6faa 100644 --- a/lib/headlines-feed/bloc/headlines_feed_bloc.dart +++ b/lib/headlines-feed/bloc/headlines_feed_bloc.dart @@ -8,7 +8,7 @@ import 'package:ht_main/headlines-feed/models/headline_filter.dart'; import 'package:ht_shared/ht_shared.dart' show Category, - Country, + // Country, // Removed as it's no longer used for headline filtering Headline, HtHttpException, Source; // Shared models and standardized exceptions @@ -73,12 +73,6 @@ class HeadlinesFeedBloc extends Bloc { .whereType() .map((s) => s.id) .toList(), - if (event.filter.eventCountries?.isNotEmpty ?? false) - 'eventCountries': - event.filter.eventCountries! - .whereType() - .map((c) => c.isoCode) - .toList(), }, limit: _headlinesFetchLimit); emit( HeadlinesFeedLoaded( @@ -189,12 +183,6 @@ class HeadlinesFeedBloc extends Bloc { .whereType() .map((s) => s.id) .toList(), - if (currentFilter.eventCountries?.isNotEmpty ?? false) - 'eventCountries': - currentFilter.eventCountries! - .whereType() - .map((c) => c.isoCode) - .toList(), }, limit: _headlinesFetchLimit, startAfterId: currentCursor, // Use determined cursor @@ -248,12 +236,6 @@ class HeadlinesFeedBloc extends Bloc { .whereType() .map((s) => s.id) .toList(), - if (currentFilter.eventCountries?.isNotEmpty ?? false) - 'eventCountries': - currentFilter.eventCountries! - .whereType() - .map((c) => c.isoCode) - .toList(), }, limit: _headlinesFetchLimit); emit( HeadlinesFeedLoaded( diff --git a/lib/headlines-feed/bloc/sources_filter_bloc.dart b/lib/headlines-feed/bloc/sources_filter_bloc.dart index b3207b12..703aab30 100644 --- a/lib/headlines-feed/bloc/sources_filter_bloc.dart +++ b/lib/headlines-feed/bloc/sources_filter_bloc.dart @@ -1,109 +1,201 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; // For transformers import 'package:equatable/equatable.dart'; -import 'package:ht_data_repository/ht_data_repository.dart'; // Generic Data Repository +import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart' - show - HtHttpException, - Source; // Shared models, including Source and standardized exceptions + show Country, HtHttpException, Source, SourceType; part 'sources_filter_event.dart'; part 'sources_filter_state.dart'; -/// {@template sources_filter_bloc} -/// Manages the state for fetching and displaying sources for filtering. -/// -/// Handles initial fetching and pagination of sources using the -/// provided [HtDataRepository]. -/// {@endtemplate} class SourcesFilterBloc extends Bloc { - /// {@macro sources_filter_bloc} - /// - /// Requires a [HtDataRepository] to interact with the data layer. - SourcesFilterBloc({required HtDataRepository sourcesRepository}) - : _sourcesRepository = sourcesRepository, - super(const SourcesFilterState()) { - on( - _onSourcesFilterRequested, - transformer: restartable(), // Only process the latest request - ); - on( - _onSourcesFilterLoadMoreRequested, - transformer: droppable(), // Ignore new requests while one is processing - ); + SourcesFilterBloc({ + required HtDataRepository sourcesRepository, + required HtDataRepository countriesRepository, + }) : _sourcesRepository = sourcesRepository, + _countriesRepository = countriesRepository, + super(const SourcesFilterState()) { + on(_onLoadSourceFilterData); + on(_onCountryCapsuleToggled); + on(_onAllSourceTypesCapsuleToggled); // Added + on(_onSourceTypeCapsuleToggled); + on(_onSourceCheckboxToggled); + on(_onClearSourceFiltersRequested); + on<_FetchFilteredSourcesRequested>(_onFetchFilteredSourcesRequested); } final HtDataRepository _sourcesRepository; + final HtDataRepository _countriesRepository; - /// Number of sources to fetch per page. - static const _sourcesLimit = 20; - - /// Handles the initial request to fetch sources. - Future _onSourcesFilterRequested( - SourcesFilterRequested event, + Future _onLoadSourceFilterData( + LoadSourceFilterData event, Emitter emit, ) async { - // Prevent fetching if already loading or successful - if (state.status == SourcesFilterStatus.loading || - state.status == SourcesFilterStatus.success) { - return; - } + emit( + state.copyWith(dataLoadingStatus: SourceFilterDataLoadingStatus.loading), + ); + try { + final availableCountries = await _countriesRepository.readAll(); + final initialSelectedSourceIds = + event.initialSelectedSources.map((s) => s.id).toSet(); - emit(state.copyWith(status: SourcesFilterStatus.loading)); + // Initialize selected capsules based on initialSelectedSources + final initialSelectedCountryIsoCodes = {}; + final initialSelectedSourceTypes = {}; + + if (event.initialSelectedSources.isNotEmpty) { + for (final source in event.initialSelectedSources) { + if (source.headquarters?.isoCode != null) { + initialSelectedCountryIsoCodes.add(source.headquarters!.isoCode); + } + if (source.sourceType != null) { + initialSelectedSourceTypes.add(source.sourceType!); + } + } + } - try { - final response = await _sourcesRepository.readAll(limit: _sourcesLimit); emit( state.copyWith( - status: SourcesFilterStatus.success, - sources: response.items, - hasMore: response.hasMore, - cursor: response.cursor, - clearError: true, // Clear any previous error + availableCountries: availableCountries.items, + finallySelectedSourceIds: initialSelectedSourceIds, + selectedCountryIsoCodes: initialSelectedCountryIsoCodes, + selectedSourceTypes: initialSelectedSourceTypes, + // Keep loading status until sources are fetched ), ); - } on HtHttpException catch (e) { - emit(state.copyWith(status: SourcesFilterStatus.failure, error: e)); + // Trigger initial fetch of displayable sources + add(const _FetchFilteredSourcesRequested()); } catch (e) { - // Catch unexpected errors - emit(state.copyWith(status: SourcesFilterStatus.failure, error: e)); + emit( + state.copyWith( + dataLoadingStatus: SourceFilterDataLoadingStatus.failure, + errorMessage: 'Failed to load filter criteria.', + ), + ); } } - /// Handles the request to load more sources for pagination. - Future _onSourcesFilterLoadMoreRequested( - SourcesFilterLoadMoreRequested event, + Future _onCountryCapsuleToggled( + CountryCapsuleToggled event, + Emitter emit, + ) async { + final currentSelected = Set.from(state.selectedCountryIsoCodes); + if (event.countryIsoCode.isEmpty) { + // "All Countries" toggled + // If "All" is tapped and it's already effectively "All" (empty set), or if it's tapped to select "All" + // we clear the set. If specific items are selected and "All" is tapped, it also clears. + // Essentially, tapping "All" always results in an empty set, meaning no country filter. + currentSelected.clear(); + } else { + // Specific country toggled + if (currentSelected.contains(event.countryIsoCode)) { + currentSelected.remove(event.countryIsoCode); + } else { + currentSelected.add(event.countryIsoCode); + } + } + emit(state.copyWith(selectedCountryIsoCodes: currentSelected)); + add(const _FetchFilteredSourcesRequested()); + } + + Future _onAllSourceTypesCapsuleToggled( + AllSourceTypesCapsuleToggled event, + Emitter emit, + ) async { + // Toggling "All" for source types means clearing any specific selections. + // If already clear, it remains clear. + emit(state.copyWith(selectedSourceTypes: {})); + add(const _FetchFilteredSourcesRequested()); + } + + Future _onSourceTypeCapsuleToggled( + SourceTypeCapsuleToggled event, Emitter emit, ) async { - // Only proceed if currently successful and has more items - if (state.status != SourcesFilterStatus.success || !state.hasMore) { - return; + final currentSelected = Set.from(state.selectedSourceTypes); + if (currentSelected.contains(event.sourceType)) { + currentSelected.remove(event.sourceType); + } else { + currentSelected.add(event.sourceType); } + // If specific types are selected, "All" is no longer true. + // The UI will derive "All" state from selectedSourceTypes.isEmpty + emit(state.copyWith(selectedSourceTypes: currentSelected)); + add(const _FetchFilteredSourcesRequested()); + } - emit(state.copyWith(status: SourcesFilterStatus.loadingMore)); + void _onSourceCheckboxToggled( + SourceCheckboxToggled event, + Emitter emit, + ) { + final currentSelected = Set.from(state.finallySelectedSourceIds); + if (event.isSelected) { + currentSelected.add(event.sourceId); + } else { + currentSelected.remove(event.sourceId); + } + emit(state.copyWith(finallySelectedSourceIds: currentSelected)); + } + Future _onClearSourceFiltersRequested( + ClearSourceFiltersRequested event, + Emitter emit, + ) async { + emit( + state.copyWith( + selectedCountryIsoCodes: {}, + selectedSourceTypes: {}, + finallySelectedSourceIds: {}, + // Keep availableCountries and availableSourceTypes + ), + ); + add(const _FetchFilteredSourcesRequested()); + } + + Future _onFetchFilteredSourcesRequested( + _FetchFilteredSourcesRequested event, + Emitter emit, + ) async { + emit( + state.copyWith( + dataLoadingStatus: SourceFilterDataLoadingStatus.loading, + displayableSources: [], // Clear previous sources + clearErrorMessage: true, + ), + ); try { - final response = await _sourcesRepository.readAll( - limit: _sourcesLimit, - startAfterId: state.cursor, // Use the cursor from the current state - ); + final queryParameters = {}; + if (state.selectedCountryIsoCodes.isNotEmpty) { + queryParameters['countries'] = state.selectedCountryIsoCodes.join(','); + } + if (state.selectedSourceTypes.isNotEmpty) { + queryParameters['sourceTypes'] = state.selectedSourceTypes + .map((st) => st.name) + .join(','); + } + + final response = await _sourcesRepository.readAllByQuery(queryParameters); emit( state.copyWith( - status: SourcesFilterStatus.success, - // Append new sources to the existing list - sources: List.of(state.sources)..addAll(response.items), - hasMore: response.hasMore, - cursor: response.cursor, + displayableSources: response.items, + dataLoadingStatus: SourceFilterDataLoadingStatus.success, ), ); } on HtHttpException catch (e) { - // Keep existing data but indicate failure - emit(state.copyWith(status: SourcesFilterStatus.failure, error: e)); + emit( + state.copyWith( + dataLoadingStatus: SourceFilterDataLoadingStatus.failure, + errorMessage: e.message, + ), + ); } catch (e) { - // Catch unexpected errors - emit(state.copyWith(status: SourcesFilterStatus.failure, error: e)); + emit( + state.copyWith( + dataLoadingStatus: SourceFilterDataLoadingStatus.failure, + errorMessage: 'An unexpected error occurred while fetching sources.', + ), + ); } } } diff --git a/lib/headlines-feed/bloc/sources_filter_event.dart b/lib/headlines-feed/bloc/sources_filter_event.dart index be225ca3..f41451a3 100644 --- a/lib/headlines-feed/bloc/sources_filter_event.dart +++ b/lib/headlines-feed/bloc/sources_filter_event.dart @@ -1,22 +1,61 @@ +// ignore_for_file: avoid_positional_boolean_parameters + part of 'sources_filter_bloc.dart'; -/// {@template sources_filter_event} -/// Base class for events related to fetching and managing source filters. -/// {@endtemplate} -sealed class SourcesFilterEvent extends Equatable { - /// {@macro sources_filter_event} +abstract class SourcesFilterEvent extends Equatable { const SourcesFilterEvent(); @override - List get props => []; + List get props => []; +} + +class LoadSourceFilterData extends SourcesFilterEvent { + const LoadSourceFilterData({this.initialSelectedSources = const []}); + + final List initialSelectedSources; + + @override + List get props => [initialSelectedSources]; +} + +class CountryCapsuleToggled extends SourcesFilterEvent { + const CountryCapsuleToggled(this.countryIsoCode); + + /// If countryIsoCode is empty, it implies "All Countries". + final String countryIsoCode; + + @override + List get props => [countryIsoCode]; +} + +class AllSourceTypesCapsuleToggled extends SourcesFilterEvent { + const AllSourceTypesCapsuleToggled(); } -/// {@template sources_filter_requested} -/// Event triggered to request the initial list of sources. -/// {@endtemplate} -final class SourcesFilterRequested extends SourcesFilterEvent {} +class SourceTypeCapsuleToggled extends SourcesFilterEvent { + const SourceTypeCapsuleToggled(this.sourceType); -/// {@template sources_filter_load_more_requested} -/// Event triggered to request the next page of sources for pagination. -/// {@endtemplate} -final class SourcesFilterLoadMoreRequested extends SourcesFilterEvent {} + final SourceType sourceType; + + @override + List get props => [sourceType]; +} + +class SourceCheckboxToggled extends SourcesFilterEvent { + const SourceCheckboxToggled(this.sourceId, this.isSelected); + + final String sourceId; + final bool isSelected; + + @override + List get props => [sourceId, isSelected]; +} + +class ClearSourceFiltersRequested extends SourcesFilterEvent { + const ClearSourceFiltersRequested(); +} + +// Internal event - not part of public API, hence leading underscore +class _FetchFilteredSourcesRequested extends SourcesFilterEvent { + const _FetchFilteredSourcesRequested(); +} diff --git a/lib/headlines-feed/bloc/sources_filter_state.dart b/lib/headlines-feed/bloc/sources_filter_state.dart index cc4e2893..22402cd5 100644 --- a/lib/headlines-feed/bloc/sources_filter_state.dart +++ b/lib/headlines-feed/bloc/sources_filter_state.dart @@ -1,76 +1,65 @@ part of 'sources_filter_bloc.dart'; -/// Enum representing the different statuses of the source filter data fetching. -enum SourcesFilterStatus { - /// Initial state, no data loaded yet. - initial, +// Import for Country, Source, SourceType will be in sources_filter_bloc.dart - /// Currently fetching the first page of sources. - loading, +enum SourceFilterDataLoadingStatus { initial, loading, success, failure } - /// Successfully loaded sources. May be loading more in the background. - success, - - /// An error occurred while fetching sources. - failure, - - /// Loading more sources for pagination (infinity scroll). - loadingMore, -} - -/// {@template sources_filter_state} -/// Represents the state for the source filter feature. -/// -/// Contains the list of fetched sources, pagination information, -/// loading/error status. -/// {@endtemplate} -final class SourcesFilterState extends Equatable { - /// {@macro sources_filter_state} +class SourcesFilterState extends Equatable { const SourcesFilterState({ - this.status = SourcesFilterStatus.initial, - this.sources = const [], - this.hasMore = true, - this.cursor, - this.error, + this.availableCountries = const [], + this.selectedCountryIsoCodes = const {}, + this.availableSourceTypes = SourceType.values, + this.selectedSourceTypes = const {}, + this.displayableSources = const [], + this.finallySelectedSourceIds = const {}, + this.dataLoadingStatus = SourceFilterDataLoadingStatus.initial, + this.errorMessage, }); - /// The current status of fetching sources. - final SourcesFilterStatus status; - - /// The list of [Source] objects fetched so far. - final List sources; - - /// Flag indicating if there are more sources available to fetch. - final bool hasMore; - - /// The cursor string to fetch the next page of sources. - /// This is typically the ID of the last fetched source. - final String? cursor; - - /// An optional error object if the status is [SourcesFilterStatus.failure]. - final Object? error; + final List availableCountries; + final Set selectedCountryIsoCodes; + final List availableSourceTypes; + final Set selectedSourceTypes; + final List displayableSources; + final Set finallySelectedSourceIds; + final SourceFilterDataLoadingStatus dataLoadingStatus; + final String? errorMessage; - /// Creates a copy of this state with the given fields replaced. SourcesFilterState copyWith({ - SourcesFilterStatus? status, - List? sources, - bool? hasMore, - String? cursor, - Object? error, - bool clearError = false, // Flag to explicitly clear the error - bool clearCursor = false, // Flag to explicitly clear the cursor + List? availableCountries, + Set? selectedCountryIsoCodes, + List? availableSourceTypes, + Set? selectedSourceTypes, + List? displayableSources, + Set? finallySelectedSourceIds, + SourceFilterDataLoadingStatus? dataLoadingStatus, + String? errorMessage, + bool clearErrorMessage = false, }) { return SourcesFilterState( - status: status ?? this.status, - sources: sources ?? this.sources, - 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, + availableCountries: availableCountries ?? this.availableCountries, + selectedCountryIsoCodes: + selectedCountryIsoCodes ?? this.selectedCountryIsoCodes, + availableSourceTypes: availableSourceTypes ?? this.availableSourceTypes, + selectedSourceTypes: selectedSourceTypes ?? this.selectedSourceTypes, + displayableSources: displayableSources ?? this.displayableSources, + finallySelectedSourceIds: + finallySelectedSourceIds ?? this.finallySelectedSourceIds, + dataLoadingStatus: dataLoadingStatus ?? this.dataLoadingStatus, + errorMessage: + clearErrorMessage ? null : errorMessage ?? this.errorMessage, ); } @override - List get props => [status, sources, hasMore, cursor, error]; + List get props => [ + availableCountries, + selectedCountryIsoCodes, + availableSourceTypes, + selectedSourceTypes, + displayableSources, + finallySelectedSourceIds, + dataLoadingStatus, + errorMessage, + ]; } diff --git a/lib/headlines-feed/models/headline_filter.dart b/lib/headlines-feed/models/headline_filter.dart index 00fa8b74..6b617875 100644 --- a/lib/headlines-feed/models/headline_filter.dart +++ b/lib/headlines-feed/models/headline_filter.dart @@ -6,7 +6,7 @@ import 'package:ht_shared/ht_shared.dart'; /// {@endtemplate} class HeadlineFilter extends Equatable { /// {@macro headline_filter} - const HeadlineFilter({this.categories, this.sources, this.eventCountries}); + const HeadlineFilter({this.categories, this.sources}); /// The list of selected category filters. /// Headlines matching *any* of these categories will be included (OR logic). @@ -16,24 +16,15 @@ class HeadlineFilter extends Equatable { /// Headlines matching *any* of these sources will be included (OR logic). final List? sources; - /// The list of selected event country filters. - /// Headlines matching *any* of these countries will be included (OR logic). - final List? eventCountries; - @override - List get props => [categories, sources, eventCountries]; + List get props => [categories, sources]; /// Creates a copy of this [HeadlineFilter] with the given fields /// replaced with the new values. - HeadlineFilter copyWith({ - List? categories, - List? sources, - List? eventCountries, - }) { + HeadlineFilter copyWith({List? categories, List? sources}) { return HeadlineFilter( categories: categories ?? this.categories, sources: sources ?? this.sources, - eventCountries: eventCountries ?? this.eventCountries, ); } } diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index 7634a54b..11cd8949 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -10,10 +10,7 @@ import 'package:ht_main/headlines-feed/bloc/headlines_feed_bloc.dart'; import 'package:ht_main/headlines-feed/widgets/headline_item_widget.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_main/shared/shared.dart'; -import 'package:ht_main/shared/widgets/failure_state_widget.dart'; -import 'package:ht_main/shared/widgets/loading_state_widget.dart'; // Import Source /// {@template headlines_feed_view} @@ -110,8 +107,8 @@ class _HeadlinesFeedPageState extends State { // 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); + (state.filter.sources?.isNotEmpty ?? false); + // (state.filter.eventCountries?.isNotEmpty ?? false); // Removed } return Stack( children: [ @@ -171,13 +168,16 @@ class _HeadlinesFeedPageState extends State { // If the list is empty after filters, show a message // with a "Clear Filters" button using FailureStateWidget. return FailureStateWidget( - message: '${l10n.headlinesFeedEmptyFilteredHeadline}\n${l10n.headlinesFeedEmptyFilteredSubheadline}', - onRetry: () { // This will be our "Clear Filters" action + message: + '${l10n.headlinesFeedEmptyFilteredHeadline}\n${l10n.headlinesFeedEmptyFilteredSubheadline}', + onRetry: () { + // This will be our "Clear Filters" action context.read().add( HeadlinesFeedFiltersCleared(), ); }, - retryButtonText: l10n.headlinesFeedClearFiltersButton, // New l10n string + retryButtonText: + l10n.headlinesFeedClearFiltersButton, // New l10n string ); } // Display the list of headlines with pull-to-refresh diff --git a/lib/headlines-feed/view/headlines_filter_page.dart b/lib/headlines-feed/view/headlines_filter_page.dart index c4ac1ca0..43d2dc4d 100644 --- a/lib/headlines-feed/view/headlines_filter_page.dart +++ b/lib/headlines-feed/view/headlines_filter_page.dart @@ -10,7 +10,7 @@ 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' - show Category, Country, Source; // Import models from ht_shared + show Category, Source; // Import models from ht_shared, Country removed /// {@template headlines_filter_page} /// A full-screen dialog page for selecting headline filters. @@ -35,7 +35,7 @@ class _HeadlinesFilterPageState extends State { /// and are only applied back to the BLoC when the user taps 'Apply'. late List _tempSelectedCategories; late List _tempSelectedSources; - late List _tempSelectedCountries; + // late List _tempSelectedCountries; // Removed @override void initState() { @@ -48,15 +48,15 @@ class _HeadlinesFilterPageState extends State { // Create copies of the lists to avoid modifying the BLoC state directly. _tempSelectedCategories = List.from(currentState.filter.categories ?? []); _tempSelectedSources = List.from(currentState.filter.sources ?? []); - _tempSelectedCountries = List.from( - currentState.filter.eventCountries ?? [], - ); + // _tempSelectedCountries = List.from( + // currentState.filter.eventCountries ?? [], + // ); // Removed } else { // Default to empty lists if the feed isn't loaded yet. This might happen // if the filter page is accessed before the initial feed load completes. _tempSelectedCategories = []; _tempSelectedSources = []; - _tempSelectedCountries = []; + // _tempSelectedCountries = []; // Removed } } @@ -155,10 +155,6 @@ class _HeadlinesFilterPageState extends State { _tempSelectedSources.isNotEmpty ? _tempSelectedSources : null, // Use null if empty - eventCountries: - _tempSelectedCountries.isNotEmpty - ? _tempSelectedCountries - : null, // Use null if empty ); // Add an event to the main HeadlinesFeedBloc to apply the @@ -198,18 +194,7 @@ class _HeadlinesFilterPageState extends State { } }, ), - _buildFilterTile( - context: context, - title: l10n.headlinesFeedFilterEventCountryLabel, - selectedCount: _tempSelectedCountries.length, - routeName: Routes.feedFilterCountriesName, - currentSelection: _tempSelectedCountries, - onResult: (result) { - if (result is List) { - setState(() => _tempSelectedCountries = result); - } - }, - ), + // _buildFilterTile for eventCountries removed ], ), ); diff --git a/lib/headlines-feed/view/source_filter_page.dart b/lib/headlines-feed/view/source_filter_page.dart index df149e46..170e3b30 100644 --- a/lib/headlines-feed/view/source_filter_page.dart +++ b/lib/headlines-feed/view/source_filter_page.dart @@ -1,225 +1,287 @@ -// // 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/sources_filter_bloc.dart'; // Import the BLoC +import 'package:ht_data_repository/ht_data_repository.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/constants.dart'; -import 'package:ht_main/shared/widgets/widgets.dart'; // For loading/error widgets -import 'package:ht_shared/ht_shared.dart' show Source; // Import Source model +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; -/// {@template source_filter_page} -/// A page dedicated to selecting news sources for filtering headlines. -/// -/// Uses [SourcesFilterBloc] to fetch sources paginatively, allows multiple -/// selections, and returns the selected list via `context.pop` when the user -/// applies the changes. -/// {@endtemplate} -class SourceFilterPage extends StatefulWidget { - /// {@macro source_filter_page} - const SourceFilterPage({super.key}); +class SourceFilterPage extends StatelessWidget { + const SourceFilterPage({super.key, this.initialSelectedSources = const []}); - @override - State createState() => _SourceFilterPageState(); -} - -/// State for the [SourceFilterPage]. -/// -/// Manages the local selection state ([_pageSelectedSources]) and interacts -/// with [SourcesFilterBloc] for data fetching and pagination. -class _SourceFilterPageState extends State { - /// Stores the sources selected by the user *on this specific page*. - /// This state is local to the `SourceFilterPage` lifecycle. - /// It's initialized in `initState` using the list of previously selected - /// sources 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 _pageSelectedSources; - - /// Scroll controller to detect when the user reaches the end of the list - /// for pagination. - final _scrollController = ScrollController(); + final List initialSelectedSources; @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 sources 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 (`_pageSelectedSources`) 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. - _pageSelectedSources = Set.from(initialSelection ?? []); - - // 3. Trigger the page-specific BLoC (SourcesFilterBloc) to start - // fetching the list of *all available* sources that the user can - // potentially select from. The BLoC handles fetching, pagination, - // loading states, and errors for the *list of options*. - context.read().add(SourcesFilterRequested()); - }); - // Add listener for pagination logic. - _scrollController.addListener(_onScroll); + Widget build(BuildContext context) { + return BlocProvider( + create: + (context) => SourcesFilterBloc( + sourcesRepository: context.read>(), + countriesRepository: context.read>(), + )..add( + LoadSourceFilterData( + initialSelectedSources: initialSelectedSources, + ), + ), + child: const _SourceFilterView(), + ); } +} - @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 sources 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 != SourcesFilterStatus.loadingMore) { - bloc.add(SourcesFilterLoadMoreRequested()); - } - } +class _SourceFilterView extends StatelessWidget { + const _SourceFilterView(); @override Widget build(BuildContext context) { final l10n = context.l10n; + final state = context.watch().state; return Scaffold( appBar: AppBar( title: Text(l10n.headlinesFeedFilterSourceLabel), actions: [ + IconButton( + icon: const Icon(Icons.clear_all), + tooltip: l10n.headlinesFeedFilterResetButton, + onPressed: () { + context.read().add( + const ClearSourceFiltersRequested(), + ); + }, + ), 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 sources (`_pageSelectedSources`) - // from this page back to the previous page (`HeadlinesFilterPage`). - // `HeadlinesFilterPage` receives this list in its `onResult` callback. - context.pop(_pageSelectedSources.toList()); + final selectedSources = + state.displayableSources + .where( + (s) => state.finallySelectedSourceIds.contains(s.id), + ) + .toList(); + Navigator.of(context).pop(selectedSources); }, ), ], ), - // Use BlocBuilder to react to state changes from SourcesFilterBloc - body: BlocBuilder( - builder: _buildBody, - ), + body: _buildBody(context, state, l10n), ); } - /// Builds the main content body based on the current [SourcesFilterState]. - Widget _buildBody(BuildContext context, SourcesFilterState state) { - final l10n = context.l10n; - - // Handle initial loading state - if (state.status == SourcesFilterStatus.loading) { + Widget _buildBody( + BuildContext context, + SourcesFilterState state, + AppLocalizations l10n, + ) { + if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.loading && + state.availableCountries.isEmpty) { return LoadingStateWidget( - icon: Icons.source_outlined, - headline: l10n.sourceFilterLoadingHeadline, - subheadline: l10n.sourceFilterLoadingSubheadline, + icon: Icons.filter_list_alt, // Added generic icon + headline: l10n.headlinesFeedFilterLoadingCriteria, + subheadline: l10n.pleaseWait, // Added generic subheadline (l10n key) ); } - - // Handle failure state (show error and retry button) - if (state.status == SourcesFilterStatus.failure && state.sources.isEmpty) { + if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.failure && + state.availableCountries.isEmpty) { return FailureStateWidget( - message: - state.error?.toString() ?? - l10n.unknownError, // Assumes unknownError exists - onRetry: - () => - context.read().add(SourcesFilterRequested()), + message: state.errorMessage ?? l10n.headlinesFeedFilterErrorCriteria, + onRetry: () { + context.read().add( + LoadSourceFilterData( + initialSelectedSources: + ModalRoute.of(context)?.settings.arguments as List? ?? + const [], + ), + ); + }, ); } - // Handle empty state (after successful load but no sources found) - if (state.status == SourcesFilterStatus.success && state.sources.isEmpty) { - return InitialStateWidget( - icon: Icons.search_off, - headline: l10n.sourceFilterEmptyHeadline, - subheadline: l10n.sourceFilterEmptySubheadline, - ); - } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCountryCapsules(context, state, l10n), + const SizedBox(height: AppSpacing.lg), + _buildSourceTypeCapsules(context, state, l10n), + const SizedBox(height: AppSpacing.lg), + Expanded(child: _buildSourcesList(context, state, l10n)), + ], + ); + } - // Handle loaded state (success or loading more) - return ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.only(bottom: AppSpacing.xxl), - itemCount: - state.sources.length + - ((state.status == SourcesFilterStatus.loadingMore || - (state.status == SourcesFilterStatus.failure && - state.sources.isNotEmpty)) - ? 1 - : 0), - itemBuilder: (context, index) { - if (index >= state.sources.length) { - if (state.status == SourcesFilterStatus.loadingMore) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), - child: Center(child: CircularProgressIndicator()), - ); - } else if (state.status == SourcesFilterStatus.failure) { - return Padding( - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.md, - horizontal: AppSpacing.lg, + Widget _buildCountryCapsules( + BuildContext context, + SourcesFilterState state, + AppLocalizations l10n, + ) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium), + child: Row( + children: [ + Text( + '${l10n.headlinesFeedFilterCountryLabel}:', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: SizedBox( + height: 40, // Fixed height for the capsule list + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: state.availableCountries.length + 1, // +1 for "All" + separatorBuilder: + (context, index) => const SizedBox(width: AppSpacing.sm), + itemBuilder: (context, index) { + if (index == 0) { + // "All" chip + return ChoiceChip( + label: Text(l10n.headlinesFeedFilterAllLabel), + selected: state.selectedCountryIsoCodes.isEmpty, + onSelected: (_) { + context.read().add( + const CountryCapsuleToggled( + '', + ), // Special value for "All" + ); + }, + ); + } + final country = state.availableCountries[index - 1]; + return ChoiceChip( + label: Text(country.name), + selected: state.selectedCountryIsoCodes.contains( + country.isoCode, + ), + onSelected: (_) { + context.read().add( + CountryCapsuleToggled(country.isoCode), + ); + }, + ); + }, ), - child: Center( - child: Text( - l10n.loadMoreError, // Assumes loadMoreError exists - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), + ), + ), + ], + ), + ); + } + + Widget _buildSourceTypeCapsules( + BuildContext context, + SourcesFilterState state, + AppLocalizations l10n, + ) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium), + child: Row( + children: [ + Text( + '${l10n.headlinesFeedFilterSourceTypeLabel}:', // Assuming l10n key exists + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: SizedBox( + height: 40, // Fixed height for the capsule list + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: + state.availableSourceTypes.length + 1, // +1 for "All" + separatorBuilder: + (context, index) => const SizedBox(width: AppSpacing.sm), + itemBuilder: (context, index) { + if (index == 0) { + // "All" chip + return ChoiceChip( + label: Text(l10n.headlinesFeedFilterAllLabel), + selected: state.selectedSourceTypes.isEmpty, + onSelected: (_) { + // For "All", if it's selected, it means no specific types are chosen. + // The BLoC should interpret an empty selectedSourceTypes set as "All". + // Toggling "All" when it's already selected (meaning list is empty) + // doesn't have a clear action here without more complex "select all" logic. + // For now, if "All" is tapped, we ensure the specific selections are cleared. + // This is best handled in the BLoC. + // We can send a specific event or a toggle that the BLoC interprets. + // For simplicity, let's make it so tapping "All" when selected does nothing, + // Tapping "All" for source types should clear specific selections. + // This is now handled by the AllSourceTypesCapsuleToggled event. + context.read().add( + const AllSourceTypesCapsuleToggled(), + ); + }, + ); + } + final sourceType = state.availableSourceTypes[index - 1]; + return ChoiceChip( + label: Text( + sourceType.name, + ), // Or a more user-friendly name + selected: state.selectedSourceTypes.contains(sourceType), + onSelected: (_) { + context.read().add( + SourceTypeCapsuleToggled(sourceType), + ); + }, + ); + }, ), - ); - } else { - return const SizedBox.shrink(); - } - } + ), + ), + ], + ), + ); + } - final source = state.sources[index]; - final isSelected = _pageSelectedSources.contains(source); + Widget _buildSourcesList( + BuildContext context, + SourcesFilterState state, + AppLocalizations l10n, + ) { + if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.loading) { + return const Center(child: CircularProgressIndicator()); + } + if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.failure && + state.displayableSources.isEmpty) { + return FailureStateWidget( + message: state.errorMessage ?? l10n.headlinesFeedFilterErrorSources, + onRetry: () { + // Dispatch a public event to reload/retry, BLoC will handle internally + context.read().add( + LoadSourceFilterData( + initialSelectedSources: + state.displayableSources + .where( + (s) => state.finallySelectedSourceIds.contains(s.id), + ) + .toList(), // Or pass current selections if needed for retry context + ), + ); + }, + ); + } + if (state.displayableSources.isEmpty) { + return Center(child: Text(l10n.headlinesFeedFilterNoSourcesMatch)); + } + return ListView.builder( + itemCount: state.displayableSources.length, + itemBuilder: (context, index) { + final source = state.displayableSources[index]; return CheckboxListTile( title: Text(source.name), - subtitle: - source.description != null && source.description!.isNotEmpty - ? Text( - source.description!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : null, - value: isSelected, + value: state.finallySelectedSourceIds.contains(source.id), onChanged: (bool? value) { - // When a checkbox state changes, update the local selection set - // (`_pageSelectedSources`) for this page. - setState(() { - if (value == true) { - // Add the source if checked. - _pageSelectedSources.add(source); - } else { - // Remove the source if unchecked. - _pageSelectedSources.remove(source); - } - }); + if (value != null) { + context.read().add( + SourceCheckboxToggled(source.id, value), + ); + } }, ); }, diff --git a/lib/headlines-search/view/headlines_search_page.dart b/lib/headlines-search/view/headlines_search_page.dart index 22976646..ce9bc380 100644 --- a/lib/headlines-search/view/headlines_search_page.dart +++ b/lib/headlines-search/view/headlines_search_page.dart @@ -197,8 +197,8 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { // Display "no results" if list is empty : headlines.isEmpty ? FailureStateWidget( - message: '${l10n.headlinesSearchNoResultsHeadline}\n${l10n.headlinesSearchNoResultsSubheadline}', - onRetry: null, // No retry/clear button for empty search results + message: + '${l10n.headlinesSearchNoResultsHeadline}\n${l10n.headlinesSearchNoResultsSubheadline}', ) // Display the list of headlines : ListView.builder( diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 09c80087..d7c4779e 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -561,5 +561,33 @@ "headlinesFeedClearFiltersButton": "مسح الفلاتر", "@headlinesFeedClearFiltersButton": { "description": "Button text to clear applied headline filters" + }, + "headlinesFeedFilterLoadingCriteria": "جارٍ تحميل خيارات التصفية...", + "@headlinesFeedFilterLoadingCriteria": { + "description": "Text shown when filter options are loading" + }, + "pleaseWait": "يرجى الانتظار...", + "@pleaseWait": { + "description": "Generic wait message" + }, + "headlinesFeedFilterErrorCriteria": "تعذر تحميل خيارات التصفية.", + "@headlinesFeedFilterErrorCriteria": { + "description": "Error message when filter options fail to load" + }, + "headlinesFeedFilterCountryLabel": "الدول", + "@headlinesFeedFilterCountryLabel": { + "description": "Label for the country filter selection" + }, + "headlinesFeedFilterSourceTypeLabel": "الأنواع", + "@headlinesFeedFilterSourceTypeLabel": { + "description": "Label for the source type filter selection" + }, + "headlinesFeedFilterErrorSources": "تعذر تحميل المصادر.", + "@headlinesFeedFilterErrorSources": { + "description": "Error message when sources fail to load for filtering" + }, + "headlinesFeedFilterNoSourcesMatch": "لا توجد مصادر تطابق الفلاتر المحددة.", + "@headlinesFeedFilterNoSourcesMatch": { + "description": "Message shown when no sources match the selected filters" } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index d520c559..a78e8ea4 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -561,5 +561,33 @@ "headlinesFeedClearFiltersButton": "Clear Filters", "@headlinesFeedClearFiltersButton": { "description": "Button text to clear applied headline filters" + }, + "headlinesFeedFilterLoadingCriteria": "Loading filter options...", + "@headlinesFeedFilterLoadingCriteria": { + "description": "Text shown when filter options are loading" + }, + "pleaseWait": "Please wait...", + "@pleaseWait": { + "description": "Generic wait message" + }, + "headlinesFeedFilterErrorCriteria": "Could not load filter options.", + "@headlinesFeedFilterErrorCriteria": { + "description": "Error message when filter options fail to load" + }, + "headlinesFeedFilterCountryLabel": "Countries", + "@headlinesFeedFilterCountryLabel": { + "description": "Label for the country filter selection" + }, + "headlinesFeedFilterSourceTypeLabel": "Types", + "@headlinesFeedFilterSourceTypeLabel": { + "description": "Label for the source type filter selection" + }, + "headlinesFeedFilterErrorSources": "Could not load sources.", + "@headlinesFeedFilterErrorSources": { + "description": "Error message when sources fail to load for filtering" + }, + "headlinesFeedFilterNoSourcesMatch": "No sources match your selected filters.", + "@headlinesFeedFilterNoSourcesMatch": { + "description": "Message shown when no sources match the selected filters" } } diff --git a/lib/router/router.dart b/lib/router/router.dart index 97005f87..4c85defe 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -14,11 +14,11 @@ import 'package:ht_main/authentication/view/request_code_page.dart'; import 'package:ht_main/headline-details/bloc/headline_details_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 new BLoC -import 'package:ht_main/headlines-feed/bloc/countries_filter_bloc.dart'; // Import new BLoC +// import 'package:ht_main/headlines-feed/bloc/countries_filter_bloc.dart'; // Import new BLoC - REMOVED import 'package:ht_main/headlines-feed/bloc/headlines_feed_bloc.dart'; import 'package:ht_main/headlines-feed/bloc/sources_filter_bloc.dart'; // Import new BLoC import 'package:ht_main/headlines-feed/view/category_filter_page.dart'; -import 'package:ht_main/headlines-feed/view/country_filter_page.dart'; +// import 'package:ht_main/headlines-feed/view/country_filter_page.dart'; // REMOVED 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'; @@ -396,28 +396,14 @@ GoRouter createRouter({ sourcesRepository: context .read>(), - ), - child: const SourceFilterPage(), - ), - ), - // Sub-route for country selection - GoRoute( - path: - Routes - .feedFilterCountries, // Relative path: 'countries' - name: Routes.feedFilterCountriesName, - // Wrap with BlocProvider - builder: - (context, state) => BlocProvider( - create: - (context) => CountriesFilterBloc( - countriesRepository: + countriesRepository: // Added missing repository context .read>(), ), - child: const CountryFilterPage(), + child: const SourceFilterPage(), ), ), + // Sub-route for country selection REMOVED ], ), ], diff --git a/lib/router/routes.dart b/lib/router/routes.dart index f9dd885d..8e9f8ae5 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -17,10 +17,6 @@ abstract final class Routes { static const feedFilterSources = 'sources'; // Path: /feed/filter/sources static const feedFilterSourcesName = 'feedFilterSources'; - static const feedFilterCountries = - 'countries'; // Path: /feed/filter/countries - static const feedFilterCountriesName = 'feedFilterCountries'; - static const search = '/search'; static const searchName = 'search'; static const account = '/account'; diff --git a/lib/shared/widgets/failure_state_widget.dart b/lib/shared/widgets/failure_state_widget.dart index 6da2a4fb..3c0ec1a2 100644 --- a/lib/shared/widgets/failure_state_widget.dart +++ b/lib/shared/widgets/failure_state_widget.dart @@ -41,7 +41,9 @@ class FailureStateWidget extends StatelessWidget { padding: const EdgeInsets.only(top: 16), child: ElevatedButton( onPressed: onRetry, - child: Text(retryButtonText ?? 'Retry'), // Use custom text or default + child: Text( + retryButtonText ?? 'Retry', + ), // Use custom text or default ), ), ],