Skip to content

Eadlines feed source filter UI fix #20

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 15 commits into from
May 30, 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
93 changes: 48 additions & 45 deletions lib/headlines-feed/bloc/headlines_feed_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,22 @@ class HeadlinesFeedBloc extends Bloc<HeadlinesFeedEvent, HeadlinesFeedState> {
) async {
emit(HeadlinesFeedLoading()); // Show loading for filter application
try {
final response = await _headlinesRepository.readAllByQuery({
if (event.filter.categories?.isNotEmpty ?? false)
'categories':
event.filter.categories!
.whereType<Category>()
.map((c) => c.id)
.toList(),
if (event.filter.sources?.isNotEmpty ?? false)
'sources':
event.filter.sources!
.whereType<Source>()
.map((s) => s.id)
.toList(),
}, limit: _headlinesFetchLimit);
final queryParams = <String, dynamic>{};
if (event.filter.categories?.isNotEmpty ?? false) {
queryParams['categories'] = event.filter.categories!
.map((c) => c.id)
.join(',');
}
if (event.filter.sources?.isNotEmpty ?? false) {
queryParams['sources'] = event.filter.sources!
.map((s) => s.id)
.join(',');
}

final response = await _headlinesRepository.readAllByQuery(
queryParams,
limit: _headlinesFetchLimit,
);
emit(
HeadlinesFeedLoaded(
headlines: response.items,
Expand Down Expand Up @@ -133,8 +135,8 @@ class HeadlinesFeedBloc extends Bloc<HeadlinesFeedEvent, HeadlinesFeedState> {
Emitter<HeadlinesFeedState> emit,
) async {
// Determine current filter and cursor based on state
var currentFilter = const HeadlineFilter();
var currentCursor =
var currentFilter = const HeadlineFilter(); // Made type explicit
var currentCursor = // Made type explicit
event.cursor; // Use event's cursor if provided (for pagination)
var currentHeadlines = <Headline>[];
var isPaginating = false;
Expand Down Expand Up @@ -169,21 +171,20 @@ class HeadlinesFeedBloc extends Bloc<HeadlinesFeedEvent, HeadlinesFeedState> {
}

try {
final queryParams = <String, dynamic>{};
if (currentFilter.categories?.isNotEmpty ?? false) {
queryParams['categories'] = currentFilter.categories!
.map((c) => c.id)
.join(',');
}
if (currentFilter.sources?.isNotEmpty ?? false) {
queryParams['sources'] = currentFilter.sources!
.map((s) => s.id)
.join(',');
}

final response = await _headlinesRepository.readAllByQuery(
{
if (currentFilter.categories?.isNotEmpty ?? false)
'categories':
currentFilter.categories!
.whereType<Category>()
.map((c) => c.id)
.toList(),
if (currentFilter.sources?.isNotEmpty ?? false)
'sources':
currentFilter.sources!
.whereType<Source>()
.map((s) => s.id)
.toList(),
},
queryParams,
limit: _headlinesFetchLimit,
startAfterId: currentCursor, // Use determined cursor
);
Expand Down Expand Up @@ -216,27 +217,29 @@ class HeadlinesFeedBloc extends Bloc<HeadlinesFeedEvent, HeadlinesFeedState> {
emit(HeadlinesFeedLoading()); // Show loading indicator for refresh

// Determine the filter currently applied in the state
var currentFilter = const HeadlineFilter();
var currentFilter = const HeadlineFilter(); // Made type explicit
if (state is HeadlinesFeedLoaded) {
currentFilter = (state as HeadlinesFeedLoaded).filter;
}

try {
final queryParams = <String, dynamic>{};
if (currentFilter.categories?.isNotEmpty ?? false) {
queryParams['categories'] = currentFilter.categories!
.map((c) => c.id)
.join(',');
}
if (currentFilter.sources?.isNotEmpty ?? false) {
queryParams['sources'] = currentFilter.sources!
.map((s) => s.id)
.join(',');
}

// Fetch the first page using the current filter
final response = await _headlinesRepository.readAllByQuery({
if (currentFilter.categories?.isNotEmpty ?? false)
'categories':
currentFilter.categories!
.whereType<Category>()
.map((c) => c.id)
.toList(),
if (currentFilter.sources?.isNotEmpty ?? false)
'sources':
currentFilter.sources!
.whereType<Source>()
.map((s) => s.id)
.toList(),
}, limit: _headlinesFetchLimit);
final response = await _headlinesRepository.readAllByQuery(
queryParams,
limit: _headlinesFetchLimit,
);
emit(
HeadlinesFeedLoaded(
headlines: response.items, // Replace headlines on refresh
Expand Down
166 changes: 86 additions & 80 deletions lib/headlines-feed/bloc/sources_filter_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ class SourcesFilterBloc extends Bloc<SourcesFilterEvent, SourcesFilterState> {
super(const SourcesFilterState()) {
on<LoadSourceFilterData>(_onLoadSourceFilterData);
on<CountryCapsuleToggled>(_onCountryCapsuleToggled);
on<AllSourceTypesCapsuleToggled>(_onAllSourceTypesCapsuleToggled); // Added
on<AllSourceTypesCapsuleToggled>(_onAllSourceTypesCapsuleToggled);
on<SourceTypeCapsuleToggled>(_onSourceTypeCapsuleToggled);
on<SourceCheckboxToggled>(_onSourceCheckboxToggled);
on<ClearSourceFiltersRequested>(_onClearSourceFiltersRequested);
on<_FetchFilteredSourcesRequested>(_onFetchFilteredSourcesRequested);
// Removed _FetchFilteredSourcesRequested event listener
}

final HtDataRepository<Source> _sourcesRepository;
Expand All @@ -40,32 +40,37 @@ class SourcesFilterBloc extends Bloc<SourcesFilterEvent, SourcesFilterState> {
final initialSelectedSourceIds =
event.initialSelectedSources.map((s) => s.id).toSet();

// 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!);
}
}
}
// Use the passed-in initial capsule selections directly
final initialSelectedCountryIsoCodes =
event.initialSelectedCountryIsoCodes;
final initialSelectedSourceTypes = event.initialSelectedSourceTypes;

final allSourcesResponse = await _sourcesRepository.readAll();
final allAvailableSources = allSourcesResponse.items;

// Initially, display all sources. Capsules are visually set but don't filter the list yet.
// Filtering will occur if a capsule is manually toggled.
// However, if initial capsule filters ARE provided, we should respect them for the initial display.
final displayableSources = _getFilteredSources(
allSources: allAvailableSources,
selectedCountries: initialSelectedCountryIsoCodes, // Use event's data
selectedTypes: initialSelectedSourceTypes, // Use event's data
);

emit(
state.copyWith(
availableCountries: availableCountries.items,
allAvailableSources: allAvailableSources,
displayableSources:
displayableSources, // Now correctly filtered if initial capsules were set
finallySelectedSourceIds: initialSelectedSourceIds,
selectedCountryIsoCodes: initialSelectedCountryIsoCodes,
selectedSourceTypes: initialSelectedSourceTypes,
// Keep loading status until sources are fetched
selectedCountryIsoCodes:
initialSelectedCountryIsoCodes, // Use event's data
selectedSourceTypes: initialSelectedSourceTypes, // Use event's data
dataLoadingStatus: SourceFilterDataLoadingStatus.success,
clearErrorMessage: true,
),
);
// Trigger initial fetch of displayable sources
add(const _FetchFilteredSourcesRequested());
} catch (e) {
emit(
state.copyWith(
Expand Down Expand Up @@ -95,34 +100,57 @@ class SourcesFilterBloc extends Bloc<SourcesFilterEvent, SourcesFilterState> {
currentSelected.add(event.countryIsoCode);
}
}
emit(state.copyWith(selectedCountryIsoCodes: currentSelected));
add(const _FetchFilteredSourcesRequested());
final newDisplayableSources = _getFilteredSources(
allSources: state.allAvailableSources,
selectedCountries: currentSelected,
selectedTypes: state.selectedSourceTypes,
);
emit(
state.copyWith(
selectedCountryIsoCodes: currentSelected,
displayableSources: newDisplayableSources,
),
);
}

Future<void> _onAllSourceTypesCapsuleToggled(
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());
) {
final newDisplayableSources = _getFilteredSources(
allSources: state.allAvailableSources,
selectedCountries: state.selectedCountryIsoCodes,
selectedTypes: {}, // Cleared source types
);
emit(
state.copyWith(
selectedSourceTypes: {},
displayableSources: newDisplayableSources,
),
);
}

Future<void> _onSourceTypeCapsuleToggled(
void _onSourceTypeCapsuleToggled(
SourceTypeCapsuleToggled event,
Emitter<SourcesFilterState> emit,
) async {
) {
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());
final newDisplayableSources = _getFilteredSources(
allSources: state.allAvailableSources,
selectedCountries: state.selectedCountryIsoCodes,
selectedTypes: currentSelected,
);
emit(
state.copyWith(
selectedSourceTypes: currentSelected,
displayableSources: newDisplayableSources,
),
);
}

void _onSourceCheckboxToggled(
Expand All @@ -147,55 +175,33 @@ class SourcesFilterBloc extends Bloc<SourcesFilterEvent, SourcesFilterState> {
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
displayableSources: List.from(state.allAvailableSources), // Reset
dataLoadingStatus: SourceFilterDataLoadingStatus.success,
clearErrorMessage: true,
),
);
try {
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(
displayableSources: response.items,
dataLoadingStatus: SourceFilterDataLoadingStatus.success,
),
);
} on HtHttpException catch (e) {
emit(
state.copyWith(
dataLoadingStatus: SourceFilterDataLoadingStatus.failure,
errorMessage: e.message,
),
);
} catch (e) {
emit(
state.copyWith(
dataLoadingStatus: SourceFilterDataLoadingStatus.failure,
errorMessage: 'An unexpected error occurred while fetching sources.',
),
);
// Helper method to filter sources based on selected countries and types
List<Source> _getFilteredSources({
required List<Source> allSources,
required Set<String> selectedCountries,
required Set<SourceType> selectedTypes,
}) {
if (selectedCountries.isEmpty && selectedTypes.isEmpty) {
return List.from(allSources); // Return all if no filters
}

return allSources.where((source) {
final matchesCountry =
selectedCountries.isEmpty ||
(source.headquarters != null &&
selectedCountries.contains(source.headquarters!.isoCode));
final matchesType =
selectedTypes.isEmpty ||
(source.sourceType != null &&
selectedTypes.contains(source.sourceType));
return matchesCountry && matchesType;
}).toList();
}
}
14 changes: 12 additions & 2 deletions lib/headlines-feed/bloc/sources_filter_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,22 @@ abstract class SourcesFilterEvent extends Equatable {
}

class LoadSourceFilterData extends SourcesFilterEvent {
const LoadSourceFilterData({this.initialSelectedSources = const []});
const LoadSourceFilterData({
this.initialSelectedSources = const [],
this.initialSelectedCountryIsoCodes = const {},
this.initialSelectedSourceTypes = const {},
});

final List<Source> initialSelectedSources;
final Set<String> initialSelectedCountryIsoCodes;
final Set<SourceType> initialSelectedSourceTypes;

@override
List<Object?> get props => [initialSelectedSources];
List<Object?> get props => [
initialSelectedSources,
initialSelectedCountryIsoCodes,
initialSelectedSourceTypes,
];
}

class CountryCapsuleToggled extends SourcesFilterEvent {
Expand Down
Loading
Loading