Skip to content

Commit 4aaa0d2

Browse files
committed
feat(source_filter): improve source filter UI
- Improved loading/error states - Added icons and labels - Enhanced visual appearance - Improved country capsule UI
1 parent 70cb038 commit 4aaa0d2

File tree

1 file changed

+142
-129
lines changed

1 file changed

+142
-129
lines changed

lib/headlines-feed/view/source_filter_page.dart

Lines changed: 142 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,19 @@ class _SourceFilterView extends StatelessWidget {
5454
@override
5555
Widget build(BuildContext context) {
5656
final l10n = context.l10n;
57+
final theme = Theme.of(context); // Get theme
58+
final textTheme = theme.textTheme; // Get textTheme
5759
final state = context.watch<SourcesFilterBloc>().state;
5860

5961
return Scaffold(
6062
appBar: AppBar(
61-
title: Text(l10n.headlinesFeedFilterSourceLabel),
63+
title: Text(
64+
l10n.headlinesFeedFilterSourceLabel,
65+
style: textTheme.titleLarge, // Apply consistent title style
66+
),
6267
actions: [
6368
IconButton(
64-
icon: const Icon(Icons.clear_all),
69+
icon: const Icon(Icons.clear_all_outlined), // Use outlined
6570
tooltip: l10n.headlinesFeedFilterResetButton,
6671
onPressed: () {
6772
context.read<SourcesFilterBloc>().add(
@@ -98,97 +103,107 @@ class _SourceFilterView extends StatelessWidget {
98103
SourcesFilterState state,
99104
AppLocalizations l10n,
100105
) {
106+
final theme = Theme.of(context);
107+
final textTheme = theme.textTheme;
108+
101109
if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.loading &&
102-
state.availableCountries.isEmpty) {
110+
state.allAvailableSources.isEmpty) { // Check allAvailableSources
103111
return LoadingStateWidget(
104-
icon: Icons.filter_list_alt, // Added generic icon
105-
headline: l10n.headlinesFeedFilterLoadingCriteria,
106-
subheadline: l10n.pleaseWait, // Added generic subheadline (l10n key)
112+
icon: Icons.source_outlined, // More relevant icon
113+
headline: l10n.sourceFilterLoadingHeadline, // Specific l10n
114+
subheadline: l10n.sourceFilterLoadingSubheadline, // Specific l10n
107115
);
108116
}
109117
if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.failure &&
110-
state.availableCountries.isEmpty) {
118+
state.allAvailableSources.isEmpty) { // Check allAvailableSources
111119
return FailureStateWidget(
112120
message: state.errorMessage ?? l10n.headlinesFeedFilterErrorCriteria,
113121
onRetry: () {
114-
context.read<SourcesFilterBloc>().add(
115-
// When retrying, we don't have initial capsule states from arguments
116-
// So, we pass empty sets, BLoC will load all sources and countries.
117-
// User can then re-apply capsule filters if needed.
118-
// Or, we could try to persist/retrieve the last known good capsule state.
119-
// For now, simple retry reloads all.
120-
const LoadSourceFilterData(),
121-
);
122+
context
123+
.read<SourcesFilterBloc>()
124+
.add(const LoadSourceFilterData());
122125
},
123126
);
124127
}
125128

126-
return Padding(
127-
padding: const EdgeInsets.only(top: AppSpacing.md),
128-
child: Column(
129-
crossAxisAlignment: CrossAxisAlignment.start,
130-
children: [
131-
_buildCountryCapsules(context, state, l10n),
132-
const SizedBox(height: AppSpacing.lg),
133-
_buildSourceTypeCapsules(context, state, l10n),
134-
const SizedBox(height: AppSpacing.lg),
135-
Expanded(child: _buildSourcesList(context, state, l10n)),
136-
],
137-
),
129+
return Column( // Removed Padding, handled by children
130+
crossAxisAlignment: CrossAxisAlignment.start,
131+
children: [
132+
_buildCountryCapsules(context, state, l10n, textTheme),
133+
const SizedBox(height: AppSpacing.md), // Adjusted spacing
134+
_buildSourceTypeCapsules(context, state, l10n, textTheme),
135+
const SizedBox(height: AppSpacing.md), // Adjusted spacing
136+
Padding(
137+
padding: const EdgeInsets.symmetric(
138+
horizontal: AppSpacing.paddingMedium,
139+
),
140+
child: Text(
141+
l10n.headlinesFeedFilterSourceLabel, // "Sources" title
142+
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
143+
),
144+
),
145+
const SizedBox(height: AppSpacing.sm),
146+
Expanded(child: _buildSourcesList(context, state, l10n, textTheme)),
147+
],
138148
);
139149
}
140150

141151
Widget _buildCountryCapsules(
142152
BuildContext context,
143153
SourcesFilterState state,
144154
AppLocalizations l10n,
155+
TextTheme textTheme, // Added textTheme
145156
) {
146157
return Padding(
147-
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium),
148-
child: Row(
158+
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium)
159+
.copyWith(top: AppSpacing.md), // Add top padding
160+
child: Column( // Use Column for label and then list
161+
crossAxisAlignment: CrossAxisAlignment.start,
149162
children: [
150163
Text(
151-
'${l10n.headlinesFeedFilterCountryLabel}:',
152-
style: Theme.of(context).textTheme.titleSmall,
164+
l10n.headlinesFeedFilterCountryLabel, // "Countries" label
165+
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
153166
),
154-
const SizedBox(width: AppSpacing.md),
155-
Expanded(
156-
child: SizedBox(
157-
height: 40, // Fixed height for the capsule list
158-
child: ListView.separated(
159-
scrollDirection: Axis.horizontal,
160-
itemCount: state.availableCountries.length + 1, // +1 for "All"
161-
separatorBuilder:
162-
(context, index) => const SizedBox(width: AppSpacing.sm),
163-
itemBuilder: (context, index) {
164-
if (index == 0) {
165-
// "All" chip
166-
return ChoiceChip(
167-
label: Text(l10n.headlinesFeedFilterAllLabel),
168-
selected: state.selectedCountryIsoCodes.isEmpty,
169-
onSelected: (_) {
170-
context.read<SourcesFilterBloc>().add(
171-
const CountryCapsuleToggled(
172-
'',
173-
), // Special value for "All"
174-
);
175-
},
176-
);
177-
}
178-
final country = state.availableCountries[index - 1];
167+
const SizedBox(height: AppSpacing.sm),
168+
SizedBox(
169+
height: AppSpacing.xl + AppSpacing.md, // Standardized height
170+
child: ListView.separated(
171+
scrollDirection: Axis.horizontal,
172+
itemCount: state.availableCountries.length + 1,
173+
separatorBuilder: (context, index) =>
174+
const SizedBox(width: AppSpacing.sm),
175+
itemBuilder: (context, index) {
176+
if (index == 0) {
179177
return ChoiceChip(
180-
label: Text(country.name),
181-
selected: state.selectedCountryIsoCodes.contains(
182-
country.isoCode,
183-
),
178+
label: Text(l10n.headlinesFeedFilterAllLabel),
179+
labelStyle: textTheme.labelLarge,
180+
selected: state.selectedCountryIsoCodes.isEmpty,
184181
onSelected: (_) {
185-
context.read<SourcesFilterBloc>().add(
186-
CountryCapsuleToggled(country.isoCode),
187-
);
182+
context
183+
.read<SourcesFilterBloc>()
184+
.add(const CountryCapsuleToggled(''));
188185
},
189186
);
190-
},
191-
),
187+
}
188+
final country = state.availableCountries[index - 1];
189+
return ChoiceChip(
190+
avatar: country.flagUrl.isNotEmpty
191+
? CircleAvatar(
192+
backgroundImage: NetworkImage(country.flagUrl),
193+
radius: AppSpacing.sm + AppSpacing.xs,
194+
)
195+
: null,
196+
label: Text(country.name),
197+
labelStyle: textTheme.labelLarge,
198+
selected:
199+
state.selectedCountryIsoCodes.contains(country.isoCode),
200+
onSelected: (_) {
201+
context
202+
.read<SourcesFilterBloc>()
203+
.add(CountryCapsuleToggled(country.isoCode));
204+
},
205+
);
206+
},
192207
),
193208
),
194209
],
@@ -200,62 +215,50 @@ class _SourceFilterView extends StatelessWidget {
200215
BuildContext context,
201216
SourcesFilterState state,
202217
AppLocalizations l10n,
218+
TextTheme textTheme, // Added textTheme
203219
) {
204220
return Padding(
205221
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium),
206-
child: Row(
222+
child: Column( // Use Column for label and then list
223+
crossAxisAlignment: CrossAxisAlignment.start,
207224
children: [
208225
Text(
209-
'${l10n.headlinesFeedFilterSourceTypeLabel}:', // Assuming l10n key exists
210-
style: Theme.of(context).textTheme.titleSmall,
226+
l10n.headlinesFeedFilterSourceTypeLabel,
227+
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
211228
),
212-
const SizedBox(width: AppSpacing.md),
213-
Expanded(
214-
child: SizedBox(
215-
height: 40, // Fixed height for the capsule list
216-
child: ListView.separated(
217-
scrollDirection: Axis.horizontal,
218-
itemCount:
219-
state.availableSourceTypes.length + 1, // +1 for "All"
220-
separatorBuilder:
221-
(context, index) => const SizedBox(width: AppSpacing.sm),
222-
itemBuilder: (context, index) {
223-
if (index == 0) {
224-
// "All" chip
225-
return ChoiceChip(
226-
label: Text(l10n.headlinesFeedFilterAllLabel),
227-
selected: state.selectedSourceTypes.isEmpty,
228-
onSelected: (_) {
229-
// For "All", if it's selected, it means no specific types are chosen.
230-
// The BLoC should interpret an empty selectedSourceTypes set as "All".
231-
// Toggling "All" when it's already selected (meaning list is empty)
232-
// doesn't have a clear action here without more complex "select all" logic.
233-
// For now, if "All" is tapped, we ensure the specific selections are cleared.
234-
// This is best handled in the BLoC.
235-
// We can send a specific event or a toggle that the BLoC interprets.
236-
// For simplicity, let's make it so tapping "All" when selected does nothing,
237-
// Tapping "All" for source types should clear specific selections.
238-
// This is now handled by the AllSourceTypesCapsuleToggled event.
239-
context.read<SourcesFilterBloc>().add(
240-
const AllSourceTypesCapsuleToggled(),
241-
);
242-
},
243-
);
244-
}
245-
final sourceType = state.availableSourceTypes[index - 1];
229+
const SizedBox(height: AppSpacing.sm),
230+
SizedBox(
231+
height: AppSpacing.xl + AppSpacing.md, // Standardized height
232+
child: ListView.separated(
233+
scrollDirection: Axis.horizontal,
234+
itemCount: state.availableSourceTypes.length + 1,
235+
separatorBuilder: (context, index) =>
236+
const SizedBox(width: AppSpacing.sm),
237+
itemBuilder: (context, index) {
238+
if (index == 0) {
246239
return ChoiceChip(
247-
label: Text(
248-
sourceType.name,
249-
), // Or a more user-friendly name
250-
selected: state.selectedSourceTypes.contains(sourceType),
240+
label: Text(l10n.headlinesFeedFilterAllLabel),
241+
labelStyle: textTheme.labelLarge,
242+
selected: state.selectedSourceTypes.isEmpty,
251243
onSelected: (_) {
252-
context.read<SourcesFilterBloc>().add(
253-
SourceTypeCapsuleToggled(sourceType),
254-
);
244+
context
245+
.read<SourcesFilterBloc>()
246+
.add(const AllSourceTypesCapsuleToggled());
255247
},
256248
);
257-
},
258-
),
249+
}
250+
final sourceType = state.availableSourceTypes[index - 1];
251+
return ChoiceChip(
252+
label: Text(sourceType.name), // Assuming SourceType.name is user-friendly
253+
labelStyle: textTheme.labelLarge,
254+
selected: state.selectedSourceTypes.contains(sourceType),
255+
onSelected: (_) {
256+
context
257+
.read<SourcesFilterBloc>()
258+
.add(SourceTypeCapsuleToggled(sourceType));
259+
},
260+
);
261+
},
259262
),
260263
),
261264
],
@@ -267,47 +270,57 @@ class _SourceFilterView extends StatelessWidget {
267270
BuildContext context,
268271
SourcesFilterState state,
269272
AppLocalizations l10n,
273+
TextTheme textTheme, // Added textTheme
270274
) {
271-
if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.loading) {
275+
if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.loading &&
276+
state.displayableSources.isEmpty) { // Added check for displayableSources
272277
return const Center(child: CircularProgressIndicator());
273278
}
274279
if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.failure &&
275280
state.displayableSources.isEmpty) {
276281
return FailureStateWidget(
277282
message: state.errorMessage ?? l10n.headlinesFeedFilterErrorSources,
278283
onRetry: () {
279-
// Dispatch a public event to reload/retry, BLoC will handle internally
280-
context.read<SourcesFilterBloc>().add(
281-
LoadSourceFilterData(
282-
initialSelectedSources:
283-
state.displayableSources
284-
.where(
285-
(s) => state.finallySelectedSourceIds.contains(s.id),
286-
)
287-
.toList(), // Or pass current selections if needed for retry context
288-
),
289-
);
284+
context
285+
.read<SourcesFilterBloc>()
286+
.add(const LoadSourceFilterData());
290287
},
291288
);
292289
}
293-
if (state.displayableSources.isEmpty) {
294-
return Center(child: Text(l10n.headlinesFeedFilterNoSourcesMatch));
290+
if (state.displayableSources.isEmpty &&
291+
state.dataLoadingStatus != SourceFilterDataLoadingStatus.loading) { // Avoid showing if still loading
292+
return Center(
293+
child: Padding(
294+
padding: const EdgeInsets.all(AppSpacing.paddingLarge),
295+
child: Text(
296+
l10n.headlinesFeedFilterNoSourcesMatch,
297+
style: textTheme.bodyLarge,
298+
textAlign: TextAlign.center,
299+
),
300+
),
301+
);
295302
}
296303

297304
return ListView.builder(
305+
padding: const EdgeInsets.symmetric(vertical: AppSpacing.paddingSmall)
306+
.copyWith(bottom: AppSpacing.xxl),
298307
itemCount: state.displayableSources.length,
299308
itemBuilder: (context, index) {
300309
final source = state.displayableSources[index];
301310
return CheckboxListTile(
302-
title: Text(source.name),
311+
title: Text(source.name, style: textTheme.titleMedium),
303312
value: state.finallySelectedSourceIds.contains(source.id),
304313
onChanged: (bool? value) {
305314
if (value != null) {
306-
context.read<SourcesFilterBloc>().add(
307-
SourceCheckboxToggled(source.id, value),
308-
);
315+
context
316+
.read<SourcesFilterBloc>()
317+
.add(SourceCheckboxToggled(source.id, value));
309318
}
310319
},
320+
controlAffinity: ListTileControlAffinity.leading,
321+
contentPadding: const EdgeInsets.symmetric(
322+
horizontal: AppSpacing.paddingMedium,
323+
),
311324
);
312325
},
313326
);

0 commit comments

Comments
 (0)