Skip to content

Commit f26bf86

Browse files
committed
feat(headlines-feed): add "Apply My Followed Countries" feature
- Implement Apply Followed Button in CountryFilterPage - Handle loading state and errors for followed countries - Update UI based on followed countries status - Refactor body building into BlocListener and BlocBuilder - Improve code readability and structure
1 parent 602f121 commit f26bf86

File tree

1 file changed

+199
-103
lines changed

1 file changed

+199
-103
lines changed
Lines changed: 199 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
//
2-
// ignore_for_file: lines_longer_than_80_chars
3-
41
import 'package:core/core.dart';
52
import 'package:flutter/material.dart';
63
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -43,10 +40,13 @@ class _CountryFilterPageState extends State<CountryFilterPage> {
4340
/// `HeadlinesFilterPage`. This ensures the checkboxes reflect the state
4441
/// from the main filter page when this page loads.
4542
late Set<Country> _pageSelectedCountries;
43+
late final CountriesFilterBloc _countriesFilterBloc;
4644

4745
@override
4846
void initState() {
4947
super.initState();
48+
_countriesFilterBloc = context.read<CountriesFilterBloc>();
49+
5050
// Initialization needs to happen after the first frame to safely access
5151
// GoRouterState.of(context).
5252
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -65,9 +65,7 @@ class _CountryFilterPageState extends State<CountryFilterPage> {
6565
// 3. Trigger the page-specific BLoC (CountriesFilterBloc) to start
6666
// fetching the list of *all available* countries that the user can
6767
// potentially select from, using the specified usage filter.
68-
context.read<CountriesFilterBloc>().add(
69-
CountriesFilterRequested(usage: widget.usage),
70-
);
68+
_countriesFilterBloc.add(CountriesFilterRequested(usage: widget.usage));
7169
});
7270
}
7371

@@ -89,6 +87,39 @@ class _CountryFilterPageState extends State<CountryFilterPage> {
8987
style: textTheme.titleLarge,
9088
),
9189
actions: [
90+
// Apply My Followed Countries Button
91+
BlocBuilder<CountriesFilterBloc, CountriesFilterState>(
92+
builder: (context, state) {
93+
// Determine if the "Apply My Followed" icon should be filled
94+
final bool isFollowedFilterActive =
95+
state.followedCountries.isNotEmpty &&
96+
_pageSelectedCountries.length ==
97+
state.followedCountries.length &&
98+
_pageSelectedCountries.every(
99+
state.followedCountries.contains,
100+
);
101+
102+
return IconButton(
103+
icon: isFollowedFilterActive
104+
? const Icon(Icons.favorite)
105+
: const Icon(Icons.favorite_border),
106+
color: isFollowedFilterActive
107+
? theme.colorScheme.primary
108+
: null,
109+
tooltip: l10n.headlinesFeedFilterApplyFollowedLabel,
110+
onPressed:
111+
state.followedCountriesStatus ==
112+
CountriesFilterStatus.loading
113+
? null // Disable while loading
114+
: () {
115+
// Dispatch event to BLoC to fetch and apply followed countries
116+
_countriesFilterBloc.add(
117+
CountriesFilterApplyFollowedRequested(),
118+
);
119+
},
120+
);
121+
},
122+
),
92123
IconButton(
93124
icon: const Icon(Icons.check),
94125
tooltip: l10n.headlinesFeedFilterApplyButton,
@@ -102,107 +133,172 @@ class _CountryFilterPageState extends State<CountryFilterPage> {
102133
),
103134
],
104135
),
105-
// Use BlocBuilder to react to state changes from CountriesFilterBloc
106-
body: BlocBuilder<CountriesFilterBloc, CountriesFilterState>(
107-
builder: _buildBody,
108-
),
109-
);
110-
}
136+
// Use BlocListener to react to state changes from CountriesFilterBloc
137+
body: BlocListener<CountriesFilterBloc, CountriesFilterState>(
138+
listenWhen: (previous, current) =>
139+
previous.followedCountriesStatus !=
140+
current.followedCountriesStatus ||
141+
previous.followedCountries != current.followedCountries,
142+
listener: (context, state) {
143+
if (state.followedCountriesStatus == CountriesFilterStatus.success) {
144+
// Update local state with followed countries from BLoC
145+
setState(() {
146+
_pageSelectedCountries = Set.from(state.followedCountries);
147+
});
148+
if (state.followedCountries.isEmpty) {
149+
ScaffoldMessenger.of(context)
150+
..hideCurrentSnackBar()
151+
..showSnackBar(
152+
SnackBar(
153+
content: Text(l10n.noFollowedItemsForFilterSnackbar),
154+
duration: const Duration(seconds: 3),
155+
),
156+
);
157+
}
158+
} else if (state.followedCountriesStatus ==
159+
CountriesFilterStatus.failure) {
160+
// Show error message if fetching followed countries failed
161+
ScaffoldMessenger.of(context)
162+
..hideCurrentSnackBar()
163+
..showSnackBar(
164+
SnackBar(
165+
content: Text(state.error?.message ?? l10n.unknownError),
166+
duration: const Duration(seconds: 3),
167+
),
168+
);
169+
}
170+
},
171+
child: BlocBuilder<CountriesFilterBloc, CountriesFilterState>(
172+
builder: (context, state) {
173+
// Determine overall loading status for the main list
174+
final bool isLoadingMainList =
175+
state.status == CountriesFilterStatus.loading;
111176

112-
/// Builds the main content body based on the current [CountriesFilterState].
113-
Widget _buildBody(BuildContext context, CountriesFilterState state) {
114-
final l10n = AppLocalizationsX(context).l10n;
115-
final theme = Theme.of(context);
116-
final textTheme = theme.textTheme;
117-
final colorScheme = theme.colorScheme;
118-
119-
// Handle initial loading state
120-
if (state.status == CountriesFilterStatus.loading) {
121-
return LoadingStateWidget(
122-
icon: Icons.public_outlined,
123-
headline: l10n.countryFilterLoadingHeadline,
124-
subheadline: l10n.countryFilterLoadingSubheadline,
125-
);
126-
}
127-
128-
// Handle failure state (show error and retry button)
129-
if (state.status == CountriesFilterStatus.failure &&
130-
state.countries.isEmpty) {
131-
return FailureStateWidget(
132-
exception: state.error ?? const UnknownException('Unknown error'),
133-
onRetry: () => context.read<CountriesFilterBloc>().add(
134-
CountriesFilterRequested(usage: widget.usage),
135-
),
136-
);
137-
}
138-
139-
// Handle empty state (after successful load but no countries found)
140-
if (state.status == CountriesFilterStatus.success &&
141-
state.countries.isEmpty) {
142-
return InitialStateWidget(
143-
icon: Icons.flag_circle_outlined,
144-
headline: l10n.countryFilterEmptyHeadline,
145-
subheadline: l10n.countryFilterEmptySubheadline,
146-
);
147-
}
148-
149-
// Handle loaded state (success)
150-
return ListView.builder(
151-
padding: const EdgeInsets.symmetric(
152-
vertical: AppSpacing.paddingSmall,
153-
).copyWith(bottom: AppSpacing.xxl),
154-
itemCount: state.countries.length,
155-
itemBuilder: (context, index) {
156-
final country = state.countries[index];
157-
final isSelected = _pageSelectedCountries.contains(country);
158-
159-
return CheckboxListTile(
160-
title: Text(country.name, style: textTheme.titleMedium),
161-
secondary: SizedBox(
162-
width: AppSpacing.xl + AppSpacing.xs,
163-
height: AppSpacing.lg + AppSpacing.sm,
164-
child: ClipRRect(
165-
// Clip the image for rounded corners if desired
166-
borderRadius: BorderRadius.circular(AppSpacing.xs / 2),
167-
child: Image.network(
168-
country.flagUrl,
169-
fit: BoxFit.cover,
170-
errorBuilder: (context, error, stackTrace) => Icon(
171-
Icons.flag_outlined,
172-
color: colorScheme.onSurfaceVariant,
173-
size: AppSpacing.lg,
177+
// Determine if followed countries are currently loading
178+
final bool isLoadingFollowedCountries =
179+
state.followedCountriesStatus == CountriesFilterStatus.loading;
180+
181+
// Handle initial loading state
182+
if (isLoadingMainList) {
183+
return LoadingStateWidget(
184+
icon: Icons.public_outlined,
185+
headline: l10n.countryFilterLoadingHeadline,
186+
subheadline: l10n.countryFilterLoadingSubheadline,
187+
);
188+
}
189+
190+
// Handle failure state (show error and retry button)
191+
if (state.status == CountriesFilterStatus.failure &&
192+
state.countries.isEmpty) {
193+
return FailureStateWidget(
194+
exception:
195+
state.error ?? const UnknownException('Unknown error'),
196+
onRetry: () => _countriesFilterBloc.add(
197+
CountriesFilterRequested(usage: widget.usage),
198+
),
199+
);
200+
}
201+
202+
// Handle empty state (after successful load but no countries found)
203+
if (state.status == CountriesFilterStatus.success &&
204+
state.countries.isEmpty) {
205+
return InitialStateWidget(
206+
icon: Icons.flag_circle_outlined,
207+
headline: l10n.countryFilterEmptyHeadline,
208+
subheadline: l10n.countryFilterEmptySubheadline,
209+
);
210+
}
211+
212+
// Handle loaded state (success)
213+
return Stack(
214+
children: [
215+
ListView.builder(
216+
padding: const EdgeInsets.symmetric(
217+
vertical: AppSpacing.paddingSmall,
218+
).copyWith(bottom: AppSpacing.xxl),
219+
itemCount: state.countries.length,
220+
itemBuilder: (context, index) {
221+
final country = state.countries[index];
222+
final isSelected = _pageSelectedCountries.contains(country);
223+
224+
return CheckboxListTile(
225+
title: Text(country.name, style: textTheme.titleMedium),
226+
secondary: SizedBox(
227+
width: AppSpacing.xl + AppSpacing.xs,
228+
height: AppSpacing.lg + AppSpacing.sm,
229+
child: ClipRRect(
230+
// Clip the image for rounded corners if desired
231+
borderRadius: BorderRadius.circular(
232+
AppSpacing.xs / 2,
233+
),
234+
child: Image.network(
235+
country.flagUrl,
236+
fit: BoxFit.cover,
237+
errorBuilder: (context, error, stackTrace) => Icon(
238+
Icons.flag_outlined,
239+
color: theme.colorScheme.onSurfaceVariant,
240+
size: AppSpacing.lg,
241+
),
242+
loadingBuilder: (context, child, loadingProgress) {
243+
if (loadingProgress == null) return child;
244+
return Center(
245+
child: CircularProgressIndicator(
246+
strokeWidth: 2,
247+
value:
248+
loadingProgress.expectedTotalBytes != null
249+
? loadingProgress.cumulativeBytesLoaded /
250+
loadingProgress.expectedTotalBytes!
251+
: null,
252+
),
253+
);
254+
},
255+
),
256+
),
257+
),
258+
value: isSelected,
259+
onChanged: (bool? value) {
260+
setState(() {
261+
if (value == true) {
262+
_pageSelectedCountries.add(country);
263+
} else {
264+
_pageSelectedCountries.remove(country);
265+
}
266+
});
267+
},
268+
controlAffinity: ListTileControlAffinity.leading,
269+
contentPadding: const EdgeInsets.symmetric(
270+
horizontal: AppSpacing.paddingMedium,
271+
),
272+
);
273+
},
174274
),
175-
loadingBuilder: (context, child, loadingProgress) {
176-
if (loadingProgress == null) return child;
177-
return Center(
178-
child: CircularProgressIndicator(
179-
strokeWidth: 2,
180-
value: loadingProgress.expectedTotalBytes != null
181-
? loadingProgress.cumulativeBytesLoaded /
182-
loadingProgress.expectedTotalBytes!
183-
: null,
275+
// Show loading overlay if followed countries are being fetched
276+
if (isLoadingFollowedCountries)
277+
Positioned.fill(
278+
child: Container(
279+
color: Colors.black54, // Semi-transparent overlay
280+
child: Center(
281+
child: Column(
282+
mainAxisAlignment: MainAxisAlignment.center,
283+
children: [
284+
const CircularProgressIndicator(),
285+
const SizedBox(height: AppSpacing.md),
286+
Text(
287+
l10n.headlinesFeedLoadingHeadline,
288+
style: theme.textTheme.titleMedium?.copyWith(
289+
color: Colors.white,
290+
),
291+
),
292+
],
293+
),
294+
),
184295
),
185-
);
186-
},
187-
),
188-
),
189-
),
190-
value: isSelected,
191-
onChanged: (bool? value) {
192-
setState(() {
193-
if (value == true) {
194-
_pageSelectedCountries.add(country);
195-
} else {
196-
_pageSelectedCountries.remove(country);
197-
}
198-
});
296+
),
297+
],
298+
);
199299
},
200-
controlAffinity: ListTileControlAffinity.leading,
201-
contentPadding: const EdgeInsets.symmetric(
202-
horizontal: AppSpacing.paddingMedium,
203-
),
204-
);
205-
},
300+
),
301+
),
206302
);
207303
}
208304
}

0 commit comments

Comments
 (0)