Skip to content

Commit 8dd8d04

Browse files
authored
Merge pull request #172 from flutter-news-app-full-source-code/fix/unamed-saved-filter-bug
Fix/unamed saved filter bug
2 parents 2f63814 + bc77b71 commit 8dd8d04

File tree

5 files changed

+320
-173
lines changed

5 files changed

+320
-173
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## 1.4.0 - Upcoming Release
44

5+
- **refactor**: improved filter page reset button to clear local selections and disable when not applicable
6+
- **feat**: auto-scroll to active filter chip in SavedFiltersBar
7+
- **fix**: resolve bug where applying a modified filter incorrectly selects the original saved filter
58
- **refactor**: relocated saved filters management to the account page and introduced reordering capability.
69
- **feat**: created a reusable, searchable multi-select page for filtering
710
- **feat**: add 'Followed' filter to quickly view content from followed items

lib/headlines-feed/bloc/headlines_feed_bloc.dart

Lines changed: 100 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'dart:async';
22

33
import 'package:bloc/bloc.dart';
44
import 'package:bloc_concurrency/bloc_concurrency.dart';
5+
import 'package:collection/collection.dart';
56
import 'package:core/core.dart';
67
import 'package:data_repository/data_repository.dart';
78
import 'package:equatable/equatable.dart';
@@ -265,19 +266,38 @@ class HeadlinesFeedBloc extends Bloc<HeadlinesFeedEvent, HeadlinesFeedState> {
265266
HeadlinesFeedFiltersApplied event,
266267
Emitter<HeadlinesFeedState> emit,
267268
) async {
268-
String? newActiveFilterId;
269+
String newActiveFilterId;
269270

270-
// If a saved filter was explicitly passed (e.g., just created), use its ID.
271+
// Prioritize the explicitly passed savedFilter to prevent race conditions.
272+
// This is crucial for the "save and apply" flow, where the AppBloc might
273+
// not have updated the savedFilters list in this bloc's state yet.
271274
if (event.savedFilter != null) {
272275
newActiveFilterId = event.savedFilter!.id;
273276
} else {
274-
// Otherwise, determine if this is a pre-existing saved filter being
275-
// re-applied or a custom one.
276-
final isSavedFilter =
277-
state.activeFilterId != 'all' &&
278-
state.activeFilterId != 'custom' &&
279-
state.activeFilterId != null;
280-
newActiveFilterId = isSavedFilter ? state.activeFilterId : 'custom';
277+
// If no filter is explicitly passed, determine if the applied filter
278+
// matches an existing saved filter. This handles re-applying a saved
279+
// filter or applying a one-time "custom" filter.
280+
final matchingSavedFilter = state.savedFilters.firstWhereOrNull((
281+
savedFilter,
282+
) {
283+
// Use sets for order-agnostic comparison of filter contents.
284+
final appliedTopics = event.filter.topics?.toSet() ?? {};
285+
final savedTopics = savedFilter.topics.toSet();
286+
final appliedSources = event.filter.sources?.toSet() ?? {};
287+
final savedSources = savedFilter.sources.toSet();
288+
final appliedCountries = event.filter.eventCountries?.toSet() ?? {};
289+
final savedCountries = savedFilter.countries.toSet();
290+
291+
return const SetEquality<Topic>().equals(appliedTopics, savedTopics) &&
292+
const SetEquality<Source>().equals(appliedSources, savedSources) &&
293+
const SetEquality<Country>().equals(
294+
appliedCountries,
295+
savedCountries,
296+
);
297+
});
298+
299+
// If a match is found, use its ID. Otherwise, mark it as 'custom'.
300+
newActiveFilterId = matchingSavedFilter?.id ?? 'custom';
281301
}
282302

283303
// When applying new filters, this is considered a major feed change,
@@ -550,15 +570,15 @@ class HeadlinesFeedBloc extends Bloc<HeadlinesFeedEvent, HeadlinesFeedState> {
550570
/// Handles the selection of the "Followed" filter from the filter bar.
551571
///
552572
/// This creates a [HeadlineFilter] from the user's followed items and
553-
/// triggers a full feed refresh.
573+
/// triggers a full feed refresh directly within this handler. It no longer
574+
/// delegates to `HeadlinesFeedFiltersApplied` to prevent a bug where the
575+
/// filter would be incorrectly identified as 'custom'.
554576
Future<void> _onFollowedFilterSelected(
555577
FollowedFilterSelected event,
556578
Emitter<HeadlinesFeedState> emit,
557579
) async {
558580
final userPreferences = _appBloc.state.userContentPreferences;
559581
if (userPreferences == null) {
560-
// This case should ideally not happen if the UI is built correctly,
561-
// as the "Followed" button is only shown when preferences are available.
562582
return;
563583
}
564584

@@ -568,15 +588,77 @@ class HeadlinesFeedBloc extends Bloc<HeadlinesFeedEvent, HeadlinesFeedState> {
568588
eventCountries: userPreferences.followedCountries,
569589
);
570590

571-
// Set the active filter ID and then dispatch an event to apply the filter
572-
// and refresh the feed.
573-
emit(state.copyWith(activeFilterId: 'followed'));
574-
add(
575-
HeadlinesFeedFiltersApplied(
591+
// This is a major feed change, so clear the ad cache.
592+
_inlineAdCacheService.clearAllAds();
593+
emit(
594+
state.copyWith(
595+
status: HeadlinesFeedStatus.loading,
576596
filter: newFilter,
577-
adThemeStyle: event.adThemeStyle,
597+
activeFilterId: 'followed',
598+
feedItems: [],
599+
clearCursor: true,
578600
),
579601
);
602+
603+
try {
604+
final currentUser = _appBloc.state.user;
605+
final appConfig = _appBloc.state.remoteConfig;
606+
607+
if (appConfig == null) {
608+
emit(state.copyWith(status: HeadlinesFeedStatus.failure));
609+
return;
610+
}
611+
612+
final headlineResponse = await _headlinesRepository.readAll(
613+
filter: _buildFilter(newFilter),
614+
pagination: const PaginationOptions(limit: _headlinesFetchLimit),
615+
sort: [const SortOption('updatedAt', SortOrder.desc)],
616+
);
617+
618+
final decorationResult = await _feedDecoratorService.decorateFeed(
619+
headlines: headlineResponse.items,
620+
user: currentUser,
621+
remoteConfig: appConfig,
622+
followedTopicIds: userPreferences.followedTopics
623+
.map((t) => t.id)
624+
.toList(),
625+
followedSourceIds: userPreferences.followedSources
626+
.map((s) => s.id)
627+
.toList(),
628+
imageStyle: _appBloc.state.settings!.feedPreferences.headlineImageStyle,
629+
adThemeStyle: event.adThemeStyle,
630+
);
631+
632+
emit(
633+
state.copyWith(
634+
status: HeadlinesFeedStatus.success,
635+
feedItems: decorationResult.decoratedItems,
636+
hasMore: headlineResponse.hasMore,
637+
cursor: headlineResponse.cursor,
638+
),
639+
);
640+
641+
final injectedDecorator = decorationResult.injectedDecorator;
642+
if (injectedDecorator != null && currentUser?.id != null) {
643+
if (injectedDecorator is CallToActionItem) {
644+
_appBloc.add(
645+
AppUserFeedDecoratorShown(
646+
userId: currentUser!.id,
647+
feedDecoratorType: injectedDecorator.decoratorType,
648+
),
649+
);
650+
} else if (injectedDecorator is ContentCollectionItem) {
651+
_appBloc.add(
652+
AppUserFeedDecoratorShown(
653+
userId: currentUser!.id,
654+
feedDecoratorType: injectedDecorator.decoratorType,
655+
),
656+
);
657+
}
658+
}
659+
} on HttpException catch (e) {
660+
emit(state.copyWith(status: HeadlinesFeedStatus.failure, error: e));
661+
}
580662
}
581663

582664
@override

lib/headlines-feed/bloc/headlines_feed_event.dart

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ final class HeadlinesFeedRefreshRequested extends HeadlinesFeedEvent {
5252
/// Event triggered when a new set of filters, selected by the user,
5353
/// should be applied to the headlines feed.
5454
/// {@endtemplate}
55+
/// The `activeFilterId` is determined within the `HeadlinesFeedBloc`.
56+
/// In most cases, this is done by comparing the applied filter against the
57+
/// list of saved filters. However, to prevent a race condition during the
58+
/// "save and apply" flow, an optional `savedFilter` can be provided to
59+
/// ensure the newly created filter is selected immediately.
5560
final class HeadlinesFeedFiltersApplied extends HeadlinesFeedEvent {
5661
/// {@macro headlines_feed_filters_applied}
5762
///
@@ -67,8 +72,8 @@ final class HeadlinesFeedFiltersApplied extends HeadlinesFeedEvent {
6772
final HeadlineFilter filter;
6873

6974
/// The optional [SavedFilter] that this filter corresponds to.
70-
/// This is used to correctly set the active filter ID when a new
71-
/// filter is saved and immediately applied.
75+
/// This is used exclusively during the "save and apply" flow to prevent
76+
/// a race condition and ensure the new filter's chip is selected.
7277
final SavedFilter? savedFilter;
7378

7479
/// The current ad theme style of the application.

lib/headlines-feed/view/headlines_filter_page.dart

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,9 @@ class _HeadlinesFilterViewState extends State<_HeadlinesFilterView> {
161161
// Add the new filter to the global AppBloc state.
162162
context.read<AppBloc>().add(SavedFilterAdded(filter: newFilter));
163163

164-
// Apply the filter to the HeadlinesFeedBloc. The page is not
165-
// popped here; that action is deferred until after the dialog
166-
// has been successfully dismissed.
164+
// Apply the newly saved filter to the HeadlinesFeedBloc. The page
165+
// is not popped here; that action is deferred until after the
166+
// dialog has been successfully dismissed.
167167
_applyFilter(newFilter);
168168
},
169169
);
@@ -186,6 +186,7 @@ class _HeadlinesFilterViewState extends State<_HeadlinesFilterView> {
186186
/// treated as a "custom" one.
187187
void _applyFilter(SavedFilter? savedFilter) {
188188
final filterState = context.read<HeadlinesFilterBloc>().state;
189+
// Create a new HeadlineFilter from the current selections in the filter bloc.
189190
final newFilter = HeadlineFilter(
190191
topics: filterState.selectedTopics.toList(),
191192
sources: filterState.selectedSources.toList(),
@@ -218,6 +219,13 @@ class _HeadlinesFilterViewState extends State<_HeadlinesFilterView> {
218219
builder: (context, filterState) {
219220
final theme = Theme.of(context);
220221

222+
// Determine if the reset button should be enabled. It's enabled only
223+
// if there are active selections to clear.
224+
final isResetEnabled =
225+
filterState.selectedTopics.isNotEmpty ||
226+
filterState.selectedSources.isNotEmpty ||
227+
filterState.selectedCountries.isNotEmpty;
228+
221229
// Determine if the "Apply" button should be enabled.
222230
final isFilterEmpty =
223231
filterState.selectedTopics.isEmpty &&
@@ -258,21 +266,16 @@ class _HeadlinesFilterViewState extends State<_HeadlinesFilterView> {
258266
style: theme.textTheme.titleLarge,
259267
),
260268
actions: [
261-
// Reset All Filters Button
269+
// Reset button to clear local selections on this page.
262270
IconButton(
263271
icon: const Icon(Icons.refresh),
264272
tooltip: l10n.headlinesFeedFilterResetButton,
265-
onPressed: () {
266-
// Dispatch event to clear filters in the feed bloc
267-
// and trigger a refresh to the "All" state.
268-
context.read<HeadlinesFeedBloc>().add(
269-
AllFilterSelected(
270-
adThemeStyle: AdThemeStyle.fromTheme(theme),
271-
),
272-
);
273-
// Close the filter page.
274-
context.pop();
275-
},
273+
// The button is disabled if there are no selections to clear.
274+
onPressed: isResetEnabled
275+
? () => context.read<HeadlinesFilterBloc>().add(
276+
const FilterSelectionsCleared(),
277+
)
278+
: null,
276279
),
277280
// Apply Filters Button
278281
IconButton(

0 commit comments

Comments
 (0)