4
4
import 'package:flutter/material.dart' ;
5
5
import 'package:flutter_bloc/flutter_bloc.dart' ;
6
6
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
7
8
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' ;
8
14
import 'package:ht_main/headlines-search/bloc/headlines_search_bloc.dart' ;
9
15
import 'package:ht_main/l10n/l10n.dart' ;
10
16
import 'package:ht_main/shared/constants/app_spacing.dart' ; // Import AppSpacing
@@ -37,58 +43,54 @@ class _HeadlinesSearchView extends StatefulWidget {
37
43
38
44
class _HeadlinesSearchViewState extends State <_HeadlinesSearchView > {
39
45
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
43
49
44
50
@override
45
51
void initState () {
46
52
super .initState ();
47
53
_scrollController.addListener (_onScroll);
48
- // Listen to text changes to control clear button visibility
49
54
_textController.addListener (() {
50
55
setState (() {
51
56
_showClearButton = _textController.text.isNotEmpty;
52
57
});
53
58
});
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));
54
64
}
55
65
56
66
@override
57
67
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 ();
62
71
super .dispose ();
63
72
}
64
73
65
- /// Handles scroll events to trigger fetching more results when near the bottom.
66
74
void _onScroll () {
67
75
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
+ );
75
80
}
76
81
}
77
82
78
- /// Checks if the scroll position is near the bottom of the list.
79
83
bool get _isBottom {
80
84
if (! _scrollController.hasClients) return false ;
81
85
final maxScroll = _scrollController.position.maxScrollExtent;
82
86
final currentScroll = _scrollController.offset;
83
- // Trigger slightly before the absolute bottom for a smoother experience
84
87
return currentScroll >= (maxScroll * 0.98 );
85
88
}
86
89
87
- /// Triggers a search request based on the current text input.
88
90
void _performSearch () {
89
91
context.read <HeadlinesSearchBloc >().add (
90
- HeadlinesSearchFetchRequested (searchTerm: _textController.text),
91
- );
92
+ HeadlinesSearchFetchRequested (searchTerm: _textController.text),
93
+ );
92
94
}
93
95
94
96
@override
@@ -100,130 +102,175 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> {
100
102
101
103
return Scaffold (
102
104
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
+ ),
104
154
title: TextField (
105
155
controller: _textController,
106
-
107
156
style: appBarTheme.titleTextStyle ?? theme.textTheme.titleLarge,
108
157
decoration: InputDecoration (
109
158
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 )),
123
162
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
129
163
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 ),
135
165
contentPadding: const EdgeInsets .symmetric (
136
166
horizontal: AppSpacing .paddingMedium,
137
- vertical:
138
- AppSpacing .paddingSmall, // Adjust vertical padding as needed
167
+ vertical: AppSpacing .paddingSmall,
139
168
),
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 ,
154
183
),
155
- // Trigger search on submit (e.g., pressing Enter on keyboard)
156
184
onSubmitted: (_) => _performSearch (),
157
185
),
158
186
actions: [
159
- // Search action button
160
187
IconButton (
161
188
icon: const Icon (Icons .search),
162
- tooltip: l10n.headlinesSearchActionTooltip, // Re-added tooltip
189
+ tooltip: l10n.headlinesSearchActionTooltip,
163
190
onPressed: _performSearch,
164
191
),
165
192
],
166
193
),
167
194
body: BlocBuilder <HeadlinesSearchBloc , HeadlinesSearchState >(
168
195
builder: (context, state) {
169
- // Handle different states of the search BLoC
170
196
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
172
203
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
+ ),
179
209
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,
184
215
) =>
185
216
errorMessage != null
186
- // Display error if present within success state
187
217
? 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
+ },
194
260
),
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
227
274
_ => const SizedBox .shrink (),
228
275
};
229
276
},
0 commit comments