Skip to content

Commit f5749d3

Browse files
authored
Merge pull request #92 from flutter-news-app-full-source-code/Add-Event-Country-and-Source-Country-Filtering
Add event country and source country filtering
2 parents 3424959 + 0c2c290 commit f5749d3

14 files changed

+208
-130
lines changed

lib/headlines-feed/bloc/countries_filter_bloc.dart

Lines changed: 17 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,20 @@ class CountriesFilterBloc
2727
_onCountriesFilterRequested,
2828
transformer: restartable(),
2929
);
30-
on<CountriesFilterLoadMoreRequested>(
31-
_onCountriesFilterLoadMoreRequested,
32-
transformer: droppable(),
33-
);
3430
}
3531

3632
final DataRepository<Country> _countriesRepository;
3733

38-
/// Number of countries to fetch per page.
39-
static const _countriesLimit = 20;
40-
41-
/// Handles the initial request to fetch countries.
34+
/// Handles the request to fetch countries based on a specific usage.
35+
///
36+
/// This method fetches a non-paginated list of countries, filtered by
37+
/// the provided [usage] (e.g., 'eventCountry', 'headquarters').
4238
Future<void> _onCountriesFilterRequested(
4339
CountriesFilterRequested event,
4440
Emitter<CountriesFilterState> emit,
4541
) async {
42+
// If already loading or successfully loaded, do not re-fetch unless explicitly
43+
// designed for a refresh mechanism (which is not the case for this usage-based fetch).
4644
if (state.status == CountriesFilterStatus.loading ||
4745
state.status == CountriesFilterStatus.success) {
4846
return;
@@ -51,53 +49,29 @@ class CountriesFilterBloc
5149
emit(state.copyWith(status: CountriesFilterStatus.loading));
5250

5351
try {
52+
// Build the filter map based on the provided usage.
53+
final filter = event.usage != null
54+
? <String, dynamic>{'usage': event.usage}
55+
: null;
56+
57+
// Fetch countries. The API for 'usage' filters is not paginated,
58+
// so we expect a complete list.
5459
final response = await _countriesRepository.readAll(
55-
pagination: const PaginationOptions(limit: _countriesLimit),
60+
filter: filter,
5661
sort: [const SortOption('name', SortOrder.asc)],
5762
);
63+
5864
emit(
5965
state.copyWith(
6066
status: CountriesFilterStatus.success,
6167
countries: response.items,
62-
hasMore: response.hasMore,
63-
cursor: response.cursor,
68+
hasMore: false, // Always false for usage-based filters
69+
cursor: null, // Always null for usage-based filters
6470
clearError: true,
6571
),
6672
);
6773
} on HttpException catch (e) {
6874
emit(state.copyWith(status: CountriesFilterStatus.failure, error: e));
6975
}
7076
}
71-
72-
/// Handles the request to load more countries for pagination.
73-
Future<void> _onCountriesFilterLoadMoreRequested(
74-
CountriesFilterLoadMoreRequested event,
75-
Emitter<CountriesFilterState> emit,
76-
) async {
77-
if (state.status != CountriesFilterStatus.success || !state.hasMore) {
78-
return;
79-
}
80-
81-
emit(state.copyWith(status: CountriesFilterStatus.loadingMore));
82-
83-
try {
84-
final response = await _countriesRepository.readAll(
85-
pagination: PaginationOptions(
86-
limit: _countriesLimit,
87-
cursor: state.cursor,
88-
),
89-
sort: [const SortOption('name', SortOrder.asc)],
90-
);
91-
emit(
92-
state.copyWith(
93-
status: CountriesFilterStatus.success,
94-
countries: List.of(state.countries)..addAll(response.items),
95-
hasMore: response.hasMore,
96-
cursor: response.cursor,
97-
),
98-
);
99-
} on HttpException catch (e) {
100-
emit(state.copyWith(status: CountriesFilterStatus.failure, error: e));
101-
}
102-
}
10377
}

lib/headlines-feed/bloc/countries_filter_event.dart

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,22 @@ sealed class CountriesFilterEvent extends Equatable {
88
const CountriesFilterEvent();
99

1010
@override
11-
List<Object> get props => [];
11+
List<Object?> get props => [];
1212
}
1313

1414
/// {@template countries_filter_requested}
1515
/// Event triggered to request the initial list of countries.
1616
/// {@endtemplate}
17-
final class CountriesFilterRequested extends CountriesFilterEvent {}
17+
final class CountriesFilterRequested extends CountriesFilterEvent {
18+
/// {@macro countries_filter_requested}
19+
///
20+
/// Optionally includes a [usage] context to filter countries by their
21+
/// relevance to headlines (e.g., 'eventCountry' or 'headquarters').
22+
const CountriesFilterRequested({this.usage});
1823

19-
/// {@template countries_filter_load_more_requested}
20-
/// Event triggered to request the next page of countries for pagination.
21-
/// {@endtemplate}
22-
final class CountriesFilterLoadMoreRequested extends CountriesFilterEvent {}
24+
/// The usage context for filtering countries (e.g., 'eventCountry', 'headquarters').
25+
final String? usage;
26+
27+
@override
28+
List<Object?> get props => [usage];
29+
}

lib/headlines-feed/bloc/headlines_feed_bloc.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ class HeadlinesFeedBloc extends Bloc<HeadlinesFeedEvent, HeadlinesFeedState> {
8181
r'$in': filter.sources!.map((s) => s.id).toList(),
8282
};
8383
}
84+
if (filter.eventCountries?.isNotEmpty ?? false) {
85+
queryFilter['eventCountry.id'] = {
86+
r'$in': filter.eventCountries!.map((c) => c.id).toList(),
87+
};
88+
}
89+
if (filter.sourceCountries?.isNotEmpty ?? false) {
90+
queryFilter['source.headquarters.id'] = {
91+
r'$in': filter.sourceCountries!.map((c) => c.id).toList(),
92+
};
93+
}
8494
return queryFilter;
8595
}
8696

lib/headlines-feed/models/headline_filter.dart

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,31 @@ class HeadlineFilter extends Equatable {
1111
this.sources,
1212
this.selectedSourceCountryIsoCodes,
1313
this.selectedSourceSourceTypes,
14+
this.eventCountries,
15+
this.sourceCountries,
1416
this.isFromFollowedItems = false,
1517
});
1618

17-
/// The list of selected topic filters.
18-
/// Headlines matching *any* of these topics will be included (OR logic).
19+
/// The list of selected topics to filter headlines by.
1920
final List<Topic>? topics;
2021

21-
/// The list of selected source filters.
22-
/// Headlines matching *any* of these sources will be included (OR logic).
22+
/// The list of selected sources to filter headlines by.
2323
final List<Source>? sources;
2424

25-
/// The set of selected country ISO codes for source filtering.
25+
/// The set of ISO codes for countries selected to filter sources by their
26+
/// headquarters.
2627
final Set<String>? selectedSourceCountryIsoCodes;
2728

28-
/// The set of selected source types for source filtering.
29+
/// The set of source types selected to filter sources by.
2930
final Set<SourceType>? selectedSourceSourceTypes;
3031

31-
/// Indicates if this filter was generated from the user's followed items.
32+
/// The list of selected event countries to filter headlines by.
33+
final List<Country>? eventCountries;
34+
35+
/// The list of selected source headquarters countries to filter headlines by.
36+
final List<Country>? sourceCountries;
37+
38+
/// Whether the filter is based on the user's followed items.
3239
final bool isFromFollowedItems;
3340

3441
@override
@@ -37,16 +44,20 @@ class HeadlineFilter extends Equatable {
3744
sources,
3845
selectedSourceCountryIsoCodes,
3946
selectedSourceSourceTypes,
47+
eventCountries,
48+
sourceCountries,
4049
isFromFollowedItems,
4150
];
4251

43-
/// Creates a copy of this [HeadlineFilter] with the given fields
52+
/// Creates a copy of this [HeadlineFilter] but with the given fields
4453
/// replaced with the new values.
4554
HeadlineFilter copyWith({
4655
List<Topic>? topics,
4756
List<Source>? sources,
4857
Set<String>? selectedSourceCountryIsoCodes,
4958
Set<SourceType>? selectedSourceSourceTypes,
59+
List<Country>? eventCountries,
60+
List<Country>? sourceCountries,
5061
bool? isFromFollowedItems,
5162
}) {
5263
return HeadlineFilter(
@@ -56,6 +67,8 @@ class HeadlineFilter extends Equatable {
5667
selectedSourceCountryIsoCodes ?? this.selectedSourceCountryIsoCodes,
5768
selectedSourceSourceTypes:
5869
selectedSourceSourceTypes ?? this.selectedSourceSourceTypes,
70+
eventCountries: eventCountries ?? this.eventCountries,
71+
sourceCountries: sourceCountries ?? this.sourceCountries,
5972
isFromFollowedItems: isFromFollowedItems ?? this.isFromFollowedItems,
6073
);
6174
}

lib/headlines-feed/view/country_filter_page.dart

Lines changed: 19 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@ import 'package:ui_kit/ui_kit.dart';
1818
/// {@endtemplate}
1919
class CountryFilterPage extends StatefulWidget {
2020
/// {@macro country_filter_page}
21-
const CountryFilterPage({super.key});
21+
const CountryFilterPage({required this.title, this.usage, super.key});
22+
23+
/// The title to display in the app bar for this filter page.
24+
final String title;
25+
26+
/// The usage context for filtering countries (e.g., 'eventCountry', 'headquarters').
27+
/// If null, fetches all countries (though this is not the primary use case for this page).
28+
final String? usage;
2229

2330
@override
2431
State<CountryFilterPage> createState() => _CountryFilterPageState();
@@ -27,7 +34,7 @@ class CountryFilterPage extends StatefulWidget {
2734
/// State for the [CountryFilterPage].
2835
///
2936
/// Manages the local selection state ([_pageSelectedCountries]) and interacts
30-
/// with [CountriesFilterBloc] for data fetching and pagination.
37+
/// with [CountriesFilterBloc] for data fetching.
3138
class _CountryFilterPageState extends State<CountryFilterPage> {
3239
/// Stores the countries selected by the user *on this specific page*.
3340
/// This state is local to the `CountryFilterPage` lifecycle.
@@ -37,10 +44,6 @@ class _CountryFilterPageState extends State<CountryFilterPage> {
3744
/// from the main filter page when this page loads.
3845
late Set<Country> _pageSelectedCountries;
3946

40-
/// Scroll controller to detect when the user reaches the end of the list
41-
/// for pagination.
42-
final _scrollController = ScrollController();
43-
4447
@override
4548
void initState() {
4649
super.initState();
@@ -61,39 +64,18 @@ class _CountryFilterPageState extends State<CountryFilterPage> {
6164

6265
// 3. Trigger the page-specific BLoC (CountriesFilterBloc) to start
6366
// fetching the list of *all available* countries that the user can
64-
// potentially select from. The BLoC handles fetching, pagination,
65-
// loading states, and errors for the *list of options*.
66-
context.read<CountriesFilterBloc>().add(CountriesFilterRequested());
67+
// potentially select from, using the specified usage filter.
68+
context.read<CountriesFilterBloc>().add(
69+
CountriesFilterRequested(usage: widget.usage),
70+
);
6771
});
68-
// Add listener for pagination logic.
69-
_scrollController.addListener(_onScroll);
7072
}
7173

7274
@override
7375
void dispose() {
74-
_scrollController
75-
..removeListener(_onScroll)
76-
..dispose();
7776
super.dispose();
7877
}
7978

80-
/// Callback function for scroll events.
81-
///
82-
/// Checks if the user has scrolled near the bottom of the list and triggers
83-
/// fetching more countries via the BLoC if available.
84-
void _onScroll() {
85-
if (!_scrollController.hasClients) return;
86-
final maxScroll = _scrollController.position.maxScrollExtent;
87-
final currentScroll = _scrollController.offset;
88-
final bloc = context.read<CountriesFilterBloc>();
89-
// Fetch more when nearing the bottom, if BLoC has more and isn't already loading more
90-
if (currentScroll >= (maxScroll * 0.9) &&
91-
bloc.state.hasMore &&
92-
bloc.state.status != CountriesFilterStatus.loadingMore) {
93-
bloc.add(CountriesFilterLoadMoreRequested());
94-
}
95-
}
96-
9779
@override
9880
Widget build(BuildContext context) {
9981
final l10n = AppLocalizationsX(context).l10n;
@@ -103,7 +85,7 @@ class _CountryFilterPageState extends State<CountryFilterPage> {
10385
return Scaffold(
10486
appBar: AppBar(
10587
title: Text(
106-
l10n.headlinesFeedFilterEventCountryLabel,
88+
widget.title, // Use the dynamic title
10789
style: textTheme.titleLarge,
10890
),
10991
actions: [
@@ -148,8 +130,9 @@ class _CountryFilterPageState extends State<CountryFilterPage> {
148130
state.countries.isEmpty) {
149131
return FailureStateWidget(
150132
exception: state.error ?? const UnknownException('Unknown error'),
151-
onRetry: () =>
152-
context.read<CountriesFilterBloc>().add(CountriesFilterRequested()),
133+
onRetry: () => context.read<CountriesFilterBloc>().add(
134+
CountriesFilterRequested(usage: widget.usage),
135+
),
153136
);
154137
}
155138

@@ -163,45 +146,13 @@ class _CountryFilterPageState extends State<CountryFilterPage> {
163146
);
164147
}
165148

166-
// Handle loaded state (success or loading more)
149+
// Handle loaded state (success)
167150
return ListView.builder(
168-
controller: _scrollController,
169151
padding: const EdgeInsets.symmetric(
170152
vertical: AppSpacing.paddingSmall,
171153
).copyWith(bottom: AppSpacing.xxl),
172-
itemCount:
173-
state.countries.length +
174-
((state.status == CountriesFilterStatus.loadingMore ||
175-
(state.status == CountriesFilterStatus.failure &&
176-
state.countries.isNotEmpty))
177-
? 1
178-
: 0),
154+
itemCount: state.countries.length,
179155
itemBuilder: (context, index) {
180-
if (index >= state.countries.length) {
181-
if (state.status == CountriesFilterStatus.loadingMore) {
182-
return const Padding(
183-
padding: EdgeInsets.symmetric(vertical: AppSpacing.lg),
184-
child: Center(child: CircularProgressIndicator()),
185-
);
186-
} else if (state.status == CountriesFilterStatus.failure) {
187-
return Padding(
188-
padding: const EdgeInsets.symmetric(
189-
vertical: AppSpacing.md,
190-
horizontal: AppSpacing.lg,
191-
),
192-
child: Center(
193-
child: Text(
194-
l10n.loadMoreError,
195-
style: textTheme.bodySmall?.copyWith(
196-
color: colorScheme.error,
197-
),
198-
),
199-
),
200-
);
201-
}
202-
return const SizedBox.shrink();
203-
}
204-
205156
final country = state.countries[index];
206157
final isSelected = _pageSelectedCountries.contains(country);
207158

lib/headlines-feed/view/headlines_feed_page.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ class _HeadlinesFeedPageState extends State<HeadlinesFeedPage> {
139139
final isFilterApplied =
140140
(state.filter.topics?.isNotEmpty ?? false) ||
141141
(state.filter.sources?.isNotEmpty ?? false) ||
142+
(state.filter.eventCountries?.isNotEmpty ?? false) ||
143+
(state.filter.sourceCountries?.isNotEmpty ?? false) ||
142144
state.filter.isFromFollowedItems;
143145
return Stack(
144146
children: [

0 commit comments

Comments
 (0)