Skip to content

Commit dfdd677

Browse files
committed
feat(search): add search by category, source, country
- Added dropdown for model type - Implemented new item widgets - Refactored search logic
1 parent 176436b commit dfdd677

File tree

1 file changed

+163
-116
lines changed

1 file changed

+163
-116
lines changed

lib/headlines-search/view/headlines_search_page.dart

Lines changed: 163 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44
import 'package:flutter/material.dart';
55
import 'package:flutter_bloc/flutter_bloc.dart';
66
import 'package:ht_main/headlines-feed/widgets/headline_item_widget.dart';
7+
import 'package:ht_main/headlines-search/models/search_model_type.dart'; // Import SearchModelType
78
import 'package:ht_main/router/routes.dart'; // Import Routes
9+
import 'package:ht_shared/ht_shared.dart'; // Import shared models
10+
// Import new item widgets
11+
import 'package:ht_main/headlines-search/widgets/category_item_widget.dart';
12+
import 'package:ht_main/headlines-search/widgets/country_item_widget.dart';
13+
import 'package:ht_main/headlines-search/widgets/source_item_widget.dart';
814
import 'package:ht_main/headlines-search/bloc/headlines_search_bloc.dart';
915
import 'package:ht_main/l10n/l10n.dart';
1016
import 'package:ht_main/shared/constants/app_spacing.dart'; // Import AppSpacing
@@ -37,58 +43,54 @@ class _HeadlinesSearchView extends StatefulWidget {
3743

3844
class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> {
3945
final _scrollController = ScrollController();
40-
final _textController =
41-
TextEditingController(); // Controller for the TextField
42-
bool _showClearButton = false; // State to control clear button visibility
46+
final _textController = TextEditingController();
47+
bool _showClearButton = false;
48+
SearchModelType _selectedModelType = SearchModelType.headline; // Initial selection
4349

4450
@override
4551
void initState() {
4652
super.initState();
4753
_scrollController.addListener(_onScroll);
48-
// Listen to text changes to control clear button visibility
4954
_textController.addListener(() {
5055
setState(() {
5156
_showClearButton = _textController.text.isNotEmpty;
5257
});
5358
});
59+
// Set initial model type in BLoC if not already set (e.g. on first load)
60+
// Though BLoC state now defaults, this ensures UI and BLoC are in sync.
61+
context
62+
.read<HeadlinesSearchBloc>()
63+
.add(HeadlinesSearchModelTypeChanged(_selectedModelType));
5464
}
5565

5666
@override
5767
void dispose() {
58-
_scrollController
59-
..removeListener(_onScroll)
60-
..dispose();
61-
_textController.dispose(); // Dispose the text controller
68+
_scrollController.removeListener(_onScroll);
69+
_scrollController.dispose();
70+
_textController.dispose();
6271
super.dispose();
6372
}
6473

65-
/// Handles scroll events to trigger fetching more results when near the bottom.
6674
void _onScroll() {
6775
final state = context.read<HeadlinesSearchBloc>().state;
68-
if (_isBottom && state is HeadlinesSearchSuccess) {
69-
final searchTerm = state.lastSearchTerm;
70-
if (state.hasMore) {
71-
context.read<HeadlinesSearchBloc>().add(
72-
HeadlinesSearchFetchRequested(searchTerm: searchTerm),
73-
);
74-
}
76+
if (_isBottom && state is HeadlinesSearchSuccess && state.hasMore) {
77+
context.read<HeadlinesSearchBloc>().add(
78+
HeadlinesSearchFetchRequested(searchTerm: state.lastSearchTerm),
79+
);
7580
}
7681
}
7782

78-
/// Checks if the scroll position is near the bottom of the list.
7983
bool get _isBottom {
8084
if (!_scrollController.hasClients) return false;
8185
final maxScroll = _scrollController.position.maxScrollExtent;
8286
final currentScroll = _scrollController.offset;
83-
// Trigger slightly before the absolute bottom for a smoother experience
8487
return currentScroll >= (maxScroll * 0.98);
8588
}
8689

87-
/// Triggers a search request based on the current text input.
8890
void _performSearch() {
8991
context.read<HeadlinesSearchBloc>().add(
90-
HeadlinesSearchFetchRequested(searchTerm: _textController.text),
91-
);
92+
HeadlinesSearchFetchRequested(searchTerm: _textController.text),
93+
);
9294
}
9395

9496
@override
@@ -100,130 +102,175 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> {
100102

101103
return Scaffold(
102104
appBar: AppBar(
103-
// Enhanced TextField integrated into the AppBar title
105+
leadingWidth: 150, // Adjust width to accommodate dropdown
106+
leading: Padding(
107+
padding: const EdgeInsets.only(left: AppSpacing.md, right: AppSpacing.sm),
108+
child: DropdownButtonFormField<SearchModelType>(
109+
value: _selectedModelType,
110+
// Use a more subtle underline or remove it if it clashes
111+
decoration: const InputDecoration(
112+
border: InputBorder.none, // Removes underline
113+
contentPadding: EdgeInsets.symmetric(
114+
horizontal: AppSpacing.xs, // Minimal horizontal padding
115+
),
116+
),
117+
// Style the dropdown text to match AppBar title
118+
style: appBarTheme.titleTextStyle ?? theme.textTheme.titleLarge,
119+
dropdownColor: colorScheme.surfaceContainerHighest, // Match theme
120+
icon: Icon(
121+
Icons.arrow_drop_down,
122+
color: appBarTheme.iconTheme?.color ?? colorScheme.onSurface,
123+
),
124+
items: SearchModelType.values.map((SearchModelType type) {
125+
return DropdownMenuItem<SearchModelType>(
126+
value: type,
127+
child: Text(
128+
type.displayName, // Using the new getter
129+
// Ensure text color contrasts with dropdownColor
130+
style: appBarTheme.titleTextStyle?.copyWith(
131+
color: colorScheme.onSurface, // Example color
132+
) ??
133+
theme.textTheme.titleLarge?.copyWith(
134+
color: colorScheme.onSurface,
135+
),
136+
),
137+
);
138+
}).toList(),
139+
onChanged: (SearchModelType? newValue) {
140+
if (newValue != null) {
141+
setState(() {
142+
_selectedModelType = newValue;
143+
});
144+
context
145+
.read<HeadlinesSearchBloc>()
146+
.add(HeadlinesSearchModelTypeChanged(newValue));
147+
if (_textController.text.isNotEmpty) {
148+
_performSearch(); // Re-trigger search with new model type
149+
}
150+
}
151+
},
152+
),
153+
),
104154
title: TextField(
105155
controller: _textController,
106-
107156
style: appBarTheme.titleTextStyle ?? theme.textTheme.titleLarge,
108157
decoration: InputDecoration(
109158
hintText: l10n.headlinesSearchHintText,
110-
111-
hintStyle:
112-
appBarTheme.titleTextStyle?.copyWith(
113-
color: (appBarTheme.titleTextStyle?.color ??
114-
colorScheme.onSurface)
115-
.withAlpha(153), // Replaced withOpacity(0.6)
116-
) ??
117-
theme.textTheme.titleLarge?.copyWith(
118-
color: colorScheme.onSurface.withAlpha(
119-
153,
120-
), // Replaced withOpacity(0.6)
121-
),
122-
// Remove the default border
159+
hintStyle: (appBarTheme.titleTextStyle ??
160+
theme.textTheme.titleLarge)
161+
?.copyWith(color: colorScheme.onSurface.withAlpha(153)),
123162
border: InputBorder.none,
124-
// Remove focused border highlight if any
125-
focusedBorder: InputBorder.none,
126-
// Remove enabled border highlight if any
127-
enabledBorder: InputBorder.none,
128-
// Add a subtle background fill
129163
filled: true,
130-
131-
fillColor: colorScheme.surface.withAlpha(
132-
26,
133-
), // Replaced withOpacity(0.1)
134-
// Apply consistent padding using AppSpacing
164+
fillColor: colorScheme.surface.withAlpha(26),
135165
contentPadding: const EdgeInsets.symmetric(
136166
horizontal: AppSpacing.paddingMedium,
137-
vertical:
138-
AppSpacing.paddingSmall, // Adjust vertical padding as needed
167+
vertical: AppSpacing.paddingSmall,
139168
),
140-
// Add a clear button that appears when text is entered
141-
suffixIcon:
142-
_showClearButton
143-
? IconButton(
144-
icon: Icon(
145-
Icons.clear,
146-
147-
color:
148-
appBarTheme.iconTheme?.color ??
149-
colorScheme.onSurface,
150-
),
151-
onPressed: _textController.clear,
152-
)
153-
: null, // No icon when text field is empty
169+
suffixIcon: _showClearButton
170+
? IconButton(
171+
icon: Icon(
172+
Icons.clear,
173+
color: appBarTheme.iconTheme?.color ??
174+
colorScheme.onSurface,
175+
),
176+
onPressed: () {
177+
_textController.clear();
178+
// Optionally clear search results when text is cleared
179+
// context.read<HeadlinesSearchBloc>().add(HeadlinesSearchModelTypeChanged(_selectedModelType));
180+
},
181+
)
182+
: null,
154183
),
155-
// Trigger search on submit (e.g., pressing Enter on keyboard)
156184
onSubmitted: (_) => _performSearch(),
157185
),
158186
actions: [
159-
// Search action button
160187
IconButton(
161188
icon: const Icon(Icons.search),
162-
tooltip: l10n.headlinesSearchActionTooltip, // Re-added tooltip
189+
tooltip: l10n.headlinesSearchActionTooltip,
163190
onPressed: _performSearch,
164191
),
165192
],
166193
),
167194
body: BlocBuilder<HeadlinesSearchBloc, HeadlinesSearchState>(
168195
builder: (context, state) {
169-
// Handle different states of the search BLoC
170196
return switch (state) {
171-
// Loading state
197+
HeadlinesSearchInitial() => InitialStateWidget(
198+
icon: Icons.search_off_rounded,
199+
headline: l10n.headlinesSearchInitialHeadline,
200+
subheadline: l10n.headlinesSearchInitialSubheadline,
201+
),
202+
// Use more generic loading text or existing keys
172203
HeadlinesSearchLoading() => InitialStateWidget(
173-
icon: Icons.manage_search, // Changed icon
174-
headline:
175-
l10n.headlinesSearchInitialHeadline, // Keep initial text for loading phase
176-
subheadline: l10n.headlinesSearchInitialSubheadline,
177-
),
178-
// Success state with results
204+
icon: Icons.manage_search,
205+
headline: l10n.headlinesFeedLoadingHeadline, // Re-use feed loading
206+
subheadline:
207+
'Searching ${state.selectedModelType.displayName.toLowerCase()}...',
208+
),
179209
HeadlinesSearchSuccess(
180-
:final headlines,
181-
:final hasMore,
182-
:final errorMessage, // Check for specific error message within success
183-
:final lastSearchTerm,
210+
results: final results,
211+
hasMore: final hasMore,
212+
errorMessage: final errorMessage,
213+
lastSearchTerm: final lastSearchTerm,
214+
selectedModelType: final resultsModelType,
184215
) =>
185216
errorMessage != null
186-
// Display error if present within success state
187217
? FailureStateWidget(
188-
message: errorMessage,
189-
onRetry: () {
190-
// Retry with the last successful search term
191-
context.read<HeadlinesSearchBloc>().add(
192-
HeadlinesSearchFetchRequested(
193-
searchTerm: lastSearchTerm,
218+
message: errorMessage,
219+
onRetry: () => context.read<HeadlinesSearchBloc>().add(
220+
HeadlinesSearchFetchRequested(
221+
searchTerm: lastSearchTerm),
222+
),
223+
)
224+
: results.isEmpty
225+
? FailureStateWidget(
226+
message:
227+
'${l10n.headlinesSearchNoResultsHeadline} for "${lastSearchTerm}" in ${resultsModelType.displayName.toLowerCase()}.\n${l10n.headlinesSearchNoResultsSubheadline}',
228+
)
229+
: ListView.builder(
230+
controller: _scrollController,
231+
itemCount:
232+
hasMore ? results.length + 1 : results.length,
233+
itemBuilder: (context, index) {
234+
if (index >= results.length) {
235+
return const Padding(
236+
padding:
237+
EdgeInsets.all(AppSpacing.paddingLarge),
238+
child:
239+
Center(child: CircularProgressIndicator()),
240+
);
241+
}
242+
final item = results[index];
243+
switch (resultsModelType) {
244+
case SearchModelType.headline:
245+
return HeadlineItemWidget(
246+
headline: item as Headline,
247+
targetRouteName:
248+
Routes.searchArticleDetailsName,
249+
);
250+
case SearchModelType.category:
251+
return CategoryItemWidget(
252+
category: item as Category);
253+
case SearchModelType.source:
254+
return SourceItemWidget(source: item as Source);
255+
case SearchModelType.country:
256+
return CountryItemWidget(
257+
country: item as Country);
258+
}
259+
},
194260
),
195-
);
196-
},
197-
)
198-
// Display "no results" if list is empty
199-
: headlines.isEmpty
200-
? FailureStateWidget(
201-
message:
202-
'${l10n.headlinesSearchNoResultsHeadline}\n${l10n.headlinesSearchNoResultsSubheadline}',
203-
)
204-
// Display the list of headlines
205-
: ListView.builder(
206-
controller: _scrollController,
207-
// Add 1 for loading indicator if more items exist
208-
itemCount:
209-
hasMore ? headlines.length + 1 : headlines.length,
210-
itemBuilder: (context, index) {
211-
// Show loading indicator at the end if hasMore
212-
if (index >= headlines.length) {
213-
// Ensure loading indicator is visible
214-
return const Padding(
215-
padding: EdgeInsets.all(AppSpacing.paddingLarge),
216-
child: Center(child: CircularProgressIndicator()),
217-
);
218-
}
219-
// Display headline item
220-
return HeadlineItemWidget(
221-
headline: headlines[index],
222-
targetRouteName: Routes.searchArticleDetailsName,
223-
);
224-
},
225-
),
226-
// Default case (should ideally not be reached if states are handled)
261+
HeadlinesSearchFailure(
262+
errorMessage: final errorMessage,
263+
lastSearchTerm: final lastSearchTerm,
264+
selectedModelType: final failedModelType
265+
) =>
266+
FailureStateWidget(
267+
message:
268+
'Failed to search $lastSearchTerm in ${failedModelType.displayName.toLowerCase()}:\n$errorMessage',
269+
onRetry: () => context.read<HeadlinesSearchBloc>().add(
270+
HeadlinesSearchFetchRequested(searchTerm: lastSearchTerm),
271+
),
272+
),
273+
// Add default case for exhaustiveness
227274
_ => const SizedBox.shrink(),
228275
};
229276
},

0 commit comments

Comments
 (0)