Skip to content

Headlines filter UI logic enhance #19

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
May 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions lib/account/view/content_preferences_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
],
);
Expand Down Expand Up @@ -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),
),
Expand Down
20 changes: 1 addition & 19 deletions lib/headlines-feed/bloc/headlines_feed_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,12 +73,6 @@ class HeadlinesFeedBloc extends Bloc<HeadlinesFeedEvent, HeadlinesFeedState> {
.whereType<Source>()
.map((s) => s.id)
.toList(),
if (event.filter.eventCountries?.isNotEmpty ?? false)
'eventCountries':
event.filter.eventCountries!
.whereType<Country>()
.map((c) => c.isoCode)
.toList(),
}, limit: _headlinesFetchLimit);
emit(
HeadlinesFeedLoaded(
Expand Down Expand Up @@ -189,12 +183,6 @@ class HeadlinesFeedBloc extends Bloc<HeadlinesFeedEvent, HeadlinesFeedState> {
.whereType<Source>()
.map((s) => s.id)
.toList(),
if (currentFilter.eventCountries?.isNotEmpty ?? false)
'eventCountries':
currentFilter.eventCountries!
.whereType<Country>()
.map((c) => c.isoCode)
.toList(),
},
limit: _headlinesFetchLimit,
startAfterId: currentCursor, // Use determined cursor
Expand Down Expand Up @@ -248,12 +236,6 @@ class HeadlinesFeedBloc extends Bloc<HeadlinesFeedEvent, HeadlinesFeedState> {
.whereType<Source>()
.map((s) => s.id)
.toList(),
if (currentFilter.eventCountries?.isNotEmpty ?? false)
'eventCountries':
currentFilter.eventCountries!
.whereType<Country>()
.map((c) => c.isoCode)
.toList(),
}, limit: _headlinesFetchLimit);
emit(
HeadlinesFeedLoaded(
Expand Down
228 changes: 160 additions & 68 deletions lib/headlines-feed/bloc/sources_filter_bloc.dart
Original file line number Diff line number Diff line change
@@ -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<SourcesFilterEvent, SourcesFilterState> {
/// {@macro sources_filter_bloc}
///
/// Requires a [HtDataRepository<Source>] to interact with the data layer.
SourcesFilterBloc({required HtDataRepository<Source> sourcesRepository})
: _sourcesRepository = sourcesRepository,
super(const SourcesFilterState()) {
on<SourcesFilterRequested>(
_onSourcesFilterRequested,
transformer: restartable(), // Only process the latest request
);
on<SourcesFilterLoadMoreRequested>(
_onSourcesFilterLoadMoreRequested,
transformer: droppable(), // Ignore new requests while one is processing
);
SourcesFilterBloc({
required HtDataRepository<Source> sourcesRepository,
required HtDataRepository<Country> countriesRepository,
}) : _sourcesRepository = sourcesRepository,
_countriesRepository = countriesRepository,
super(const SourcesFilterState()) {
on<LoadSourceFilterData>(_onLoadSourceFilterData);
on<CountryCapsuleToggled>(_onCountryCapsuleToggled);
on<AllSourceTypesCapsuleToggled>(_onAllSourceTypesCapsuleToggled); // Added
on<SourceTypeCapsuleToggled>(_onSourceTypeCapsuleToggled);
on<SourceCheckboxToggled>(_onSourceCheckboxToggled);
on<ClearSourceFiltersRequested>(_onClearSourceFiltersRequested);
on<_FetchFilteredSourcesRequested>(_onFetchFilteredSourcesRequested);
}

final HtDataRepository<Source> _sourcesRepository;
final HtDataRepository<Country> _countriesRepository;

/// Number of sources to fetch per page.
static const _sourcesLimit = 20;

/// Handles the initial request to fetch sources.
Future<void> _onSourcesFilterRequested(
SourcesFilterRequested event,
Future<void> _onLoadSourceFilterData(
LoadSourceFilterData event,
Emitter<SourcesFilterState> 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 = <String>{};
final initialSelectedSourceTypes = <SourceType>{};

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<void> _onSourcesFilterLoadMoreRequested(
SourcesFilterLoadMoreRequested event,
Future<void> _onCountryCapsuleToggled(
CountryCapsuleToggled event,
Emitter<SourcesFilterState> emit,
) async {
final currentSelected = Set<String>.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<void> _onAllSourceTypesCapsuleToggled(
AllSourceTypesCapsuleToggled event,
Emitter<SourcesFilterState> 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<void> _onSourceTypeCapsuleToggled(
SourceTypeCapsuleToggled event,
Emitter<SourcesFilterState> emit,
) async {
// Only proceed if currently successful and has more items
if (state.status != SourcesFilterStatus.success || !state.hasMore) {
return;
final currentSelected = Set<SourceType>.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<SourcesFilterState> emit,
) {
final currentSelected = Set<String>.from(state.finallySelectedSourceIds);
if (event.isSelected) {
currentSelected.add(event.sourceId);
} else {
currentSelected.remove(event.sourceId);
}
emit(state.copyWith(finallySelectedSourceIds: currentSelected));
}

Future<void> _onClearSourceFiltersRequested(
ClearSourceFiltersRequested event,
Emitter<SourcesFilterState> emit,
) async {
emit(
state.copyWith(
selectedCountryIsoCodes: {},
selectedSourceTypes: {},
finallySelectedSourceIds: {},
// Keep availableCountries and availableSourceTypes
),
);
add(const _FetchFilteredSourcesRequested());
}

Future<void> _onFetchFilteredSourcesRequested(
_FetchFilteredSourcesRequested event,
Emitter<SourcesFilterState> 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 = <String, dynamic>{};
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.',
),
);
}
}
}
Loading
Loading