Skip to content

Commit cb3cec3

Browse files
committed
feat(search): implement load more functionality
- Added HeadlinesSearchLoadMore event - Implemented _onSearchLoadMore handler - Added cursor based pagination - Display loading indicator
1 parent ef49f20 commit cb3cec3

File tree

5 files changed

+125
-13
lines changed

5 files changed

+125
-13
lines changed

lib/headlines-search/bloc/headlines_search_bloc.dart

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ class HeadlinesSearchBloc
1818
.asyncExpand(mapper),
1919
);
2020
on<HeadlinesSearchRequested>(_onSearchRequested);
21+
on<HeadlinesSearchLoadMore>(_onSearchLoadMore);
2122
}
2223

2324
final HtHeadlinesRepository _headlinesRepository;
2425
String _searchTerm = '';
26+
static const _limit = 10;
2527

2628
Future<void> _onSearchTermChanged(
2729
HeadlinesSearchTermChanged event,
@@ -38,13 +40,54 @@ class HeadlinesSearchBloc
3840
Emitter<HeadlinesSearchState> emit,
3941
) async {
4042
if (_searchTerm.isEmpty) {
41-
return; // Don't search if the term is empty
43+
return;
4244
}
4345
emit(HeadlinesSearchLoading());
4446
try {
45-
final response =
46-
await _headlinesRepository.searchHeadlines(query: _searchTerm);
47-
emit(HeadlinesSearchLoaded(headlines: response.items));
47+
final response = await _headlinesRepository.searchHeadlines(
48+
query: _searchTerm,
49+
limit: _limit,
50+
);
51+
emit(
52+
HeadlinesSearchLoaded(
53+
headlines: response.items,
54+
hasReachedMax: !response.hasMore,
55+
cursor: response.cursor,
56+
),
57+
);
58+
} on HeadlinesSearchException catch (e) {
59+
emit(HeadlinesSearchError(message: e.message));
60+
} catch (e) {
61+
emit(HeadlinesSearchError(message: e.toString()));
62+
}
63+
}
64+
65+
Future<void> _onSearchLoadMore(
66+
HeadlinesSearchLoadMore event,
67+
Emitter<HeadlinesSearchState> emit,
68+
) async {
69+
if (state is! HeadlinesSearchLoaded) return;
70+
71+
final currentState = state as HeadlinesSearchLoaded;
72+
73+
if (currentState.hasReachedMax) return;
74+
75+
try {
76+
final response = await _headlinesRepository.searchHeadlines(
77+
query: _searchTerm,
78+
limit: _limit,
79+
startAfterId: currentState.cursor,
80+
);
81+
emit(
82+
response.items.isEmpty
83+
? currentState.copyWith(hasReachedMax: true)
84+
: currentState.copyWith(
85+
headlines: List.of(currentState.headlines)
86+
..addAll(response.items),
87+
hasReachedMax: !response.hasMore,
88+
cursor: response.cursor,
89+
),
90+
);
4891
} on HeadlinesSearchException catch (e) {
4992
emit(HeadlinesSearchError(message: e.message));
5093
} catch (e) {

lib/headlines-search/bloc/headlines_search_event.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ final class HeadlinesSearchTermChanged extends HeadlinesSearchEvent {
1717
}
1818

1919
final class HeadlinesSearchRequested extends HeadlinesSearchEvent {}
20+
21+
final class HeadlinesSearchLoadMore extends HeadlinesSearchEvent {}

lib/headlines-search/bloc/headlines_search_state.dart

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,38 @@ sealed class HeadlinesSearchState extends Equatable {
44
const HeadlinesSearchState();
55

66
@override
7-
List<Object> get props => [];
7+
List<Object?> get props => [];
88
}
99

1010
final class HeadlinesSearchInitial extends HeadlinesSearchState {}
1111

1212
final class HeadlinesSearchLoading extends HeadlinesSearchState {}
1313

1414
final class HeadlinesSearchLoaded extends HeadlinesSearchState {
15-
const HeadlinesSearchLoaded({required this.headlines});
15+
const HeadlinesSearchLoaded({
16+
required this.headlines,
17+
this.hasReachedMax = false,
18+
this.cursor,
19+
});
1620

1721
final List<Headline> headlines;
22+
final bool hasReachedMax;
23+
final String? cursor;
24+
25+
HeadlinesSearchLoaded copyWith({
26+
List<Headline>? headlines,
27+
bool? hasReachedMax,
28+
String? cursor,
29+
}) {
30+
return HeadlinesSearchLoaded(
31+
headlines: headlines ?? this.headlines,
32+
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
33+
cursor: cursor ?? this.cursor,
34+
);
35+
}
1836

1937
@override
20-
List<Object> get props => [headlines];
38+
List<Object?> get props => [headlines, hasReachedMax, cursor];
2139
}
2240

2341
final class HeadlinesSearchError extends HeadlinesSearchState {

lib/headlines-search/view/headlines_search_view.dart

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,22 @@ import 'package:ht_main/shared/widgets/failure_state_widget.dart';
77
import 'package:ht_main/shared/widgets/initial_state_widget.dart';
88
import 'package:ht_main/shared/widgets/loading_state_widget.dart';
99

10-
class HeadlinesSearchView extends StatelessWidget {
10+
class HeadlinesSearchView extends StatefulWidget {
1111
const HeadlinesSearchView({super.key});
1212

13+
@override
14+
State<HeadlinesSearchView> createState() => _HeadlinesSearchViewState();
15+
}
16+
17+
class _HeadlinesSearchViewState extends State<HeadlinesSearchView> {
18+
final _scrollController = ScrollController();
19+
20+
@override
21+
void initState() {
22+
super.initState();
23+
_scrollController.addListener(_onScroll);
24+
}
25+
1326
@override
1427
Widget build(BuildContext context) {
1528
return Scaffold(
@@ -48,8 +61,14 @@ class HeadlinesSearchView extends StatelessWidget {
4861
headline: 'Loading...',
4962
subheadline: 'Fetching headlines',
5063
),
51-
HeadlinesSearchLoaded(:final headlines) =>
52-
_HeadlinesSearchLoadedView(headlines: headlines),
64+
HeadlinesSearchLoaded(
65+
:final headlines,
66+
:final hasReachedMax
67+
) =>
68+
_HeadlinesSearchLoadedView(
69+
headlines: headlines,
70+
hasReachedMax: hasReachedMax,
71+
),
5372
HeadlinesSearchError(:final message) => FailureStateWidget(
5473
message: message,
5574
onRetry: () {
@@ -63,18 +82,48 @@ class HeadlinesSearchView extends StatelessWidget {
6382
),
6483
);
6584
}
85+
86+
@override
87+
void dispose() {
88+
_scrollController
89+
..removeListener(_onScroll)
90+
..dispose();
91+
super.dispose();
92+
}
93+
94+
void _onScroll() {
95+
if (_isBottom) {
96+
context.read<HeadlinesSearchBloc>().add(HeadlinesSearchLoadMore());
97+
}
98+
}
99+
100+
bool get _isBottom {
101+
if (!_scrollController.hasClients) return false;
102+
final maxScroll = _scrollController.position.maxScrollExtent;
103+
final currentScroll = _scrollController.offset;
104+
return currentScroll >= (maxScroll * 0.9);
105+
}
66106
}
67107

68108
class _HeadlinesSearchLoadedView extends StatelessWidget {
69-
const _HeadlinesSearchLoadedView({required this.headlines});
109+
const _HeadlinesSearchLoadedView({
110+
required this.headlines,
111+
required this.hasReachedMax,
112+
});
70113

71114
final List<Headline> headlines;
115+
final bool hasReachedMax;
72116

73117
@override
74118
Widget build(BuildContext context) {
75119
return ListView.builder(
76-
itemCount: headlines.length,
120+
itemCount: hasReachedMax ? headlines.length : headlines.length + 1,
77121
itemBuilder: (context, index) {
122+
if (index >= headlines.length) {
123+
return const Center(
124+
child: CircularProgressIndicator(),
125+
);
126+
}
78127
return HeadlineItemWidget(headline: headlines[index]);
79128
},
80129
);

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: ht_main
22
description: main headlines toolkit mobile app.
3-
version: 0.19.0
3+
version: 0.20.0
44
publish_to: none
55
repository: https://github.com/headlines-toolkit/ht-main
66
environment:

0 commit comments

Comments
 (0)