Skip to content

Commit 9e2bfa4

Browse files
committed
feat(feed): implement headlines filtering
- Added filter UI and logic - Implemented category selection - Implemented source selection - Implemented country selection
1 parent 93b90d0 commit 9e2bfa4

13 files changed

+1372
-351
lines changed

lib/headlines-feed/bloc/headlines_feed_bloc.dart

Lines changed: 140 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -3,201 +3,219 @@ import 'dart:async';
33
import 'package:bloc/bloc.dart';
44
import 'package:bloc_concurrency/bloc_concurrency.dart';
55
import 'package:equatable/equatable.dart';
6-
import 'package:ht_categories_client/ht_categories_client.dart';
7-
import 'package:ht_countries_client/ht_countries_client.dart';
86
import 'package:ht_headlines_client/ht_headlines_client.dart'; // Import for Headline and Exceptions
97
import 'package:ht_headlines_repository/ht_headlines_repository.dart';
108
import 'package:ht_main/headlines-feed/models/headline_filter.dart';
11-
import 'package:ht_sources_client/ht_sources_client.dart';
129

1310
part 'headlines_feed_event.dart';
1411
part 'headlines_feed_state.dart';
1512

1613
/// {@template headlines_feed_bloc}
17-
/// A Bloc that manages the headlines feed.
14+
/// Manages the state for the headlines feed feature.
1815
///
19-
/// It handles fetching and refreshing headlines data using the
20-
/// [HtHeadlinesRepository].
16+
/// Handles fetching headlines, applying filters, pagination, and refreshing
17+
/// the feed using the provided [HtHeadlinesRepository].
2118
/// {@endtemplate}
2219
class HeadlinesFeedBloc extends Bloc<HeadlinesFeedEvent, HeadlinesFeedState> {
2320
/// {@macro headlines_feed_bloc}
21+
///
22+
/// Requires a [HtHeadlinesRepository] to interact with the data layer.
2423
HeadlinesFeedBloc({required HtHeadlinesRepository headlinesRepository})
2524
: _headlinesRepository = headlinesRepository,
2625
super(HeadlinesFeedLoading()) {
2726
on<HeadlinesFeedFetchRequested>(
2827
_onHeadlinesFeedFetchRequested,
29-
transformer: sequential(),
28+
transformer:
29+
sequential(), // Ensures fetch requests are processed one by one
3030
);
3131
on<HeadlinesFeedRefreshRequested>(
3232
_onHeadlinesFeedRefreshRequested,
33-
transformer: restartable(),
33+
transformer:
34+
restartable(), // Ensures only the latest refresh is processed
3435
);
35-
on<HeadlinesFeedFilterChanged>(_onHeadlinesFeedFilterChanged);
36+
on<HeadlinesFeedFiltersApplied>(_onHeadlinesFeedFiltersApplied);
37+
on<HeadlinesFeedFiltersCleared>(_onHeadlinesFeedFiltersCleared);
3638
}
3739

3840
final HtHeadlinesRepository _headlinesRepository;
3941

42+
/// The number of headlines to fetch per page during pagination or initial load.
4043
static const _headlinesFetchLimit = 10;
4144

42-
Future<void> _onHeadlinesFeedFilterChanged(
43-
HeadlinesFeedFilterChanged event,
45+
/// Handles the [HeadlinesFeedFiltersApplied] event.
46+
///
47+
/// Emits [HeadlinesFeedLoading] state, then fetches the first page of
48+
/// headlines using the filters provided in the event. Updates the state
49+
/// with the new headlines and the applied filter. Emits [HeadlinesFeedError]
50+
/// if fetching fails.
51+
Future<void> _onHeadlinesFeedFiltersApplied(
52+
HeadlinesFeedFiltersApplied event,
4453
Emitter<HeadlinesFeedState> emit,
4554
) async {
46-
emit(HeadlinesFeedLoading());
55+
emit(HeadlinesFeedLoading()); // Show loading for filter application
4756
try {
48-
// Use list-based filters from the event
4957
final response = await _headlinesRepository.getHeadlines(
5058
limit: _headlinesFetchLimit,
51-
categories: event.categories,
52-
sources: event.sources,
53-
eventCountries: event.eventCountries,
59+
categories: event.filter.categories,
60+
sources: event.filter.sources,
61+
eventCountries: event.filter.eventCountries,
5462
);
55-
final newFilter =
56-
(state is HeadlinesFeedLoaded)
57-
? (state as HeadlinesFeedLoaded).filter.copyWith(
58-
// Update copyWith call
59-
categories: event.categories,
60-
sources: event.sources,
61-
eventCountries: event.eventCountries,
62-
)
63-
: HeadlineFilter(
64-
// Update constructor call
65-
categories: event.categories,
66-
sources: event.sources,
67-
eventCountries: event.eventCountries,
68-
);
6963
emit(
7064
HeadlinesFeedLoaded(
7165
headlines: response.items,
7266
hasMore: response.hasMore,
7367
cursor: response.cursor,
74-
filter: newFilter,
68+
filter: event.filter, // Store the applied filter
7569
),
7670
);
7771
} on HeadlinesFetchException catch (e) {
7872
emit(HeadlinesFeedError(message: e.message));
79-
} catch (_) {
73+
} catch (e, st) {
74+
// Log the error and stack trace for unexpected errors
75+
// Consider using a proper logging framework
76+
print('Unexpected error in _onHeadlinesFeedFiltersApplied: $e\n$st');
8077
emit(const HeadlinesFeedError(message: 'An unexpected error occurred'));
8178
}
8279
}
8380

84-
/// Handles [HeadlinesFeedFetchRequested] events.
81+
/// Handles clearing all applied filters.
8582
///
86-
/// Fetches headlines from the repository and emits
87-
/// [HeadlinesFeedLoading], and either [HeadlinesFeedLoaded] or
88-
/// [HeadlinesFeedError] states.
83+
/// Fetches the first page of headlines without any filters.
84+
Future<void> _onHeadlinesFeedFiltersCleared(
85+
HeadlinesFeedFiltersCleared event,
86+
Emitter<HeadlinesFeedState> emit,
87+
) async {
88+
emit(HeadlinesFeedLoading()); // Show loading indicator
89+
try {
90+
// Fetch the first page with no filters
91+
final response = await _headlinesRepository.getHeadlines(
92+
limit: _headlinesFetchLimit,
93+
);
94+
emit(
95+
HeadlinesFeedLoaded(
96+
headlines: response.items,
97+
hasMore: response.hasMore,
98+
cursor: response.cursor,
99+
),
100+
);
101+
} on HeadlinesFetchException catch (e) {
102+
emit(HeadlinesFeedError(message: e.message));
103+
} catch (e, st) {
104+
// Log the error and stack trace for unexpected errors
105+
print('Unexpected error in _onHeadlinesFeedFiltersCleared: $e\n$st');
106+
emit(const HeadlinesFeedError(message: 'An unexpected error occurred'));
107+
}
108+
}
109+
110+
/// Handles the [HeadlinesFeedFetchRequested] event for initial load and pagination.
111+
///
112+
/// Determines if it's an initial load or pagination based on the current state
113+
/// and the presence of a cursor in the event. Fetches headlines using the
114+
/// currently active filter stored in the state. Emits appropriate loading
115+
/// states ([HeadlinesFeedLoading] or [HeadlinesFeedLoadingSilently]) and
116+
/// updates the state with fetched headlines or an error.
89117
Future<void> _onHeadlinesFeedFetchRequested(
90118
HeadlinesFeedFetchRequested event,
91119
Emitter<HeadlinesFeedState> emit,
92120
) async {
93-
if (state is HeadlinesFeedLoaded &&
94-
(state as HeadlinesFeedLoaded).hasMore) {
95-
final currentState = state as HeadlinesFeedLoaded;
96-
emit(HeadlinesFeedLoadingSilently());
97-
try {
98-
// Use list-based filters from the current state's filter
99-
final response = await _headlinesRepository.getHeadlines(
100-
limit: _headlinesFetchLimit,
101-
startAfterId: currentState.cursor,
102-
categories: currentState.filter.categories,
103-
sources: currentState.filter.sources,
104-
eventCountries: currentState.filter.eventCountries,
105-
);
106-
emit(
107-
HeadlinesFeedLoaded(
108-
headlines: currentState.headlines + response.items,
109-
hasMore: response.hasMore,
110-
cursor: response.cursor,
111-
filter: currentState.filter,
112-
),
113-
);
114-
} on HeadlinesFetchException catch (e) {
115-
emit(HeadlinesFeedError(message: e.message));
116-
} catch (_) {
117-
emit(const HeadlinesFeedError(message: 'An unexpected error occurred'));
121+
// Determine current filter and cursor based on state
122+
var currentFilter = const HeadlineFilter();
123+
var currentCursor =
124+
event.cursor; // Use event's cursor if provided (for pagination)
125+
var currentHeadlines = <Headline>[];
126+
var isPaginating = false;
127+
128+
if (state is HeadlinesFeedLoaded) {
129+
final loadedState = state as HeadlinesFeedLoaded;
130+
currentFilter = loadedState.filter;
131+
// Only use state's cursor if event's cursor is null (i.e., not explicit pagination request)
132+
currentCursor ??= loadedState.cursor;
133+
currentHeadlines = loadedState.headlines;
134+
// Check if we should paginate
135+
isPaginating = event.cursor != null && loadedState.hasMore;
136+
if (isPaginating && state is HeadlinesFeedLoadingSilently) {
137+
return; // Avoid concurrent pagination
138+
}
139+
if (!loadedState.hasMore && event.cursor != null) {
140+
return; // Don't fetch if no more items
118141
}
142+
} else if (state is HeadlinesFeedLoading ||
143+
state is HeadlinesFeedLoadingSilently) {
144+
// Avoid concurrent fetches if already loading, unless it's explicit pagination
145+
if (event.cursor == null) return;
146+
}
147+
148+
// Emit appropriate loading state
149+
if (isPaginating) {
150+
emit(HeadlinesFeedLoadingSilently());
119151
} else {
152+
// Initial load or load after error/clear
120153
emit(HeadlinesFeedLoading());
121-
try {
122-
// Use list-based filters from the current state's filter (if loaded)
123-
final response = await _headlinesRepository.getHeadlines(
124-
limit: _headlinesFetchLimit,
125-
categories:
126-
state is HeadlinesFeedLoaded
127-
? (state as HeadlinesFeedLoaded).filter.categories
128-
: null,
129-
sources:
130-
state is HeadlinesFeedLoaded
131-
? (state as HeadlinesFeedLoaded).filter.sources
132-
: null,
133-
eventCountries:
134-
state is HeadlinesFeedLoaded
135-
? (state as HeadlinesFeedLoaded).filter.eventCountries
136-
: null,
137-
);
138-
emit(
139-
HeadlinesFeedLoaded(
140-
headlines: response.items,
141-
hasMore: response.hasMore,
142-
cursor: response.cursor,
143-
filter:
144-
state is HeadlinesFeedLoaded
145-
? (state as HeadlinesFeedLoaded).filter
146-
: const HeadlineFilter(),
147-
),
148-
);
149-
} on HeadlinesFetchException catch (e) {
150-
emit(HeadlinesFeedError(message: e.message));
151-
} catch (_) {
152-
emit(const HeadlinesFeedError(message: 'An unexpected error occurred'));
153-
}
154+
currentHeadlines = []; // Reset headlines on non-pagination fetch
155+
}
156+
157+
try {
158+
final response = await _headlinesRepository.getHeadlines(
159+
limit: _headlinesFetchLimit,
160+
startAfterId: currentCursor, // Use determined cursor
161+
categories: currentFilter.categories,
162+
sources: currentFilter.sources,
163+
eventCountries: currentFilter.eventCountries,
164+
);
165+
emit(
166+
HeadlinesFeedLoaded(
167+
// Append if paginating, otherwise replace
168+
headlines:
169+
isPaginating ? currentHeadlines + response.items : response.items,
170+
hasMore: response.hasMore,
171+
cursor: response.cursor,
172+
filter: currentFilter, // Preserve the filter
173+
),
174+
);
175+
} on HeadlinesFetchException catch (e) {
176+
emit(HeadlinesFeedError(message: e.message));
177+
} catch (e, st) {
178+
print('Unexpected error in _onHeadlinesFeedFetchRequested: $e\n$st');
179+
emit(const HeadlinesFeedError(message: 'An unexpected error occurred'));
154180
}
155181
}
156182

157-
/// Handles [HeadlinesFeedRefreshRequested] events.
183+
/// Handles [HeadlinesFeedRefreshRequested] events for pull-to-refresh.
158184
///
159-
/// Fetches headlines from the repository and emits
160-
/// [HeadlinesFeedLoading], and either [HeadlinesFeedLoaded] or
161-
/// [HeadlinesFeedError] states.
162-
///
163-
/// Uses `restartable` transformer to ensure that only the latest
164-
/// refresh request is processed.
185+
/// Fetches the first page of headlines using the currently applied filter (if any).
186+
/// Uses `restartable` transformer to ensure only the latest request is processed.
165187
Future<void> _onHeadlinesFeedRefreshRequested(
166188
HeadlinesFeedRefreshRequested event,
167189
Emitter<HeadlinesFeedState> emit,
168190
) async {
169-
emit(HeadlinesFeedLoading());
191+
emit(HeadlinesFeedLoading()); // Show loading indicator for refresh
192+
193+
// Determine the filter currently applied in the state
194+
var currentFilter = const HeadlineFilter();
195+
if (state is HeadlinesFeedLoaded) {
196+
currentFilter = (state as HeadlinesFeedLoaded).filter;
197+
}
198+
170199
try {
171-
// Use list-based filters from the current state's filter (if loaded)
200+
// Fetch the first page using the current filter
172201
final response = await _headlinesRepository.getHeadlines(
173-
limit: 20, // Consider using _headlinesFetchLimit here too?
174-
categories:
175-
state is HeadlinesFeedLoaded
176-
? (state as HeadlinesFeedLoaded).filter.categories
177-
: null,
178-
sources:
179-
state is HeadlinesFeedLoaded
180-
? (state as HeadlinesFeedLoaded).filter.sources
181-
: null,
182-
eventCountries:
183-
state is HeadlinesFeedLoaded
184-
? (state as HeadlinesFeedLoaded).filter.eventCountries
185-
: null,
202+
limit: _headlinesFetchLimit,
203+
categories: currentFilter.categories,
204+
sources: currentFilter.sources,
205+
eventCountries: currentFilter.eventCountries,
186206
);
187207
emit(
188208
HeadlinesFeedLoaded(
189-
headlines: response.items,
209+
headlines: response.items, // Replace headlines on refresh
190210
hasMore: response.hasMore,
191211
cursor: response.cursor,
192-
filter:
193-
state is HeadlinesFeedLoaded
194-
? (state as HeadlinesFeedLoaded).filter
195-
: const HeadlineFilter(),
212+
filter: currentFilter, // Preserve the filter
196213
),
197214
);
198215
} on HeadlinesFetchException catch (e) {
199216
emit(HeadlinesFeedError(message: e.message));
200-
} catch (_) {
217+
} catch (e, st) {
218+
print('Unexpected error in _onHeadlinesFeedRefreshRequested: $e\n$st');
201219
emit(const HeadlinesFeedError(message: 'An unexpected error occurred'));
202220
}
203221
}

0 commit comments

Comments
 (0)