Skip to content

Commit dd2ee66

Browse files
committed
feat(headlines-feed): add "My Followed" sources to filter and improve loading states
- Add "Apply My Followed Sources" button with favorite icon toggle - Implement loading overlay for followed sources fetch - Show snackbar for empty followed sources list - Display error sn
1 parent 52e4ab3 commit dd2ee66

File tree

1 file changed

+157
-77
lines changed

1 file changed

+157
-77
lines changed

lib/headlines-feed/view/source_filter_page.dart

Lines changed: 157 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:core/core.dart';
44
import 'package:data_repository/data_repository.dart';
55
import 'package:flutter/material.dart';
66
import 'package:flutter_bloc/flutter_bloc.dart';
7+
import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; // Import AppBloc
78
import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/sources_filter_bloc.dart';
89
import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart';
910
import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart';
@@ -32,15 +33,17 @@ class SourceFilterPage extends StatelessWidget {
3233
@override
3334
Widget build(BuildContext context) {
3435
return BlocProvider(
35-
create: (context) =>
36-
SourcesFilterBloc(
37-
sourcesRepository: context.read<DataRepository<Source>>(),
38-
countriesRepository: context.read<DataRepository<Country>>(),
39-
)..add(
40-
LoadSourceFilterData(
41-
initialSelectedSources: initialSelectedSources,
42-
),
36+
create: (context) => SourcesFilterBloc(
37+
sourcesRepository: context.read<DataRepository<Source>>(),
38+
countriesRepository: context.read<DataRepository<Country>>(),
39+
userContentPreferencesRepository:
40+
context.read<DataRepository<UserContentPreferences>>(),
41+
appBloc: context.read<AppBloc>(),
42+
)..add(
43+
LoadSourceFilterData(
44+
initialSelectedSources: initialSelectedSources,
4345
),
46+
),
4447
child: const _SourceFilterView(),
4548
);
4649
}
@@ -63,13 +66,44 @@ class _SourceFilterView extends StatelessWidget {
6366
style: textTheme.titleLarge,
6467
),
6568
actions: [
69+
// Apply My Followed Sources Button
70+
BlocBuilder<SourcesFilterBloc, SourcesFilterState>(
71+
builder: (context, state) {
72+
// Determine if the "Apply My Followed" icon should be filled
73+
final bool isFollowedFilterActive =
74+
state.followedSources.isNotEmpty &&
75+
state.finallySelectedSourceIds.length ==
76+
state.followedSources.length &&
77+
state.followedSources.every(
78+
(source) =>
79+
state.finallySelectedSourceIds.contains(source.id),
80+
);
81+
82+
return IconButton(
83+
icon: isFollowedFilterActive
84+
? const Icon(Icons.favorite)
85+
: const Icon(Icons.favorite_border),
86+
color: isFollowedFilterActive ? theme.colorScheme.primary : null,
87+
tooltip: l10n.headlinesFeedFilterApplyFollowedLabel,
88+
onPressed: state.followedSourcesStatus ==
89+
SourceFilterDataLoadingStatus.loading
90+
? null // Disable while loading
91+
: () {
92+
// Dispatch event to BLoC to fetch and apply followed sources
93+
context.read<SourcesFilterBloc>().add(
94+
SourcesFilterApplyFollowedRequested(),
95+
);
96+
},
97+
);
98+
},
99+
),
66100
IconButton(
67101
icon: const Icon(Icons.clear_all_outlined),
68102
tooltip: l10n.headlinesFeedFilterResetButton,
69103
onPressed: () {
70104
context.read<SourcesFilterBloc>().add(
71-
const ClearSourceFiltersRequested(),
72-
);
105+
const ClearSourceFiltersRequested(),
106+
);
73107
},
74108
),
75109
IconButton(
@@ -85,60 +119,111 @@ class _SourceFilterView extends StatelessWidget {
85119
),
86120
],
87121
),
88-
body: _buildBody(context, state, l10n),
89-
);
90-
}
91-
92-
Widget _buildBody(
93-
BuildContext context,
94-
SourcesFilterState state,
95-
AppLocalizations l10n,
96-
) {
97-
final theme = Theme.of(context);
98-
final textTheme = theme.textTheme;
99-
100-
if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.loading &&
101-
state.allAvailableSources.isEmpty) {
102-
// Check allAvailableSources
103-
return LoadingStateWidget(
104-
icon: Icons.source_outlined,
105-
headline: l10n.sourceFilterLoadingHeadline,
106-
subheadline: l10n.sourceFilterLoadingSubheadline,
107-
);
108-
}
109-
if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.failure &&
110-
state.allAvailableSources.isEmpty) {
111-
// Check allAvailableSources
112-
return FailureStateWidget(
113-
exception:
114-
state.error ??
115-
const UnknownException('Failed to load source filter data.'),
116-
onRetry: () {
117-
context.read<SourcesFilterBloc>().add(const LoadSourceFilterData());
122+
body: BlocListener<SourcesFilterBloc, SourcesFilterState>(
123+
listenWhen: (previous, current) =>
124+
previous.followedSourcesStatus != current.followedSourcesStatus ||
125+
previous.followedSources != current.followedSources,
126+
listener: (context, state) {
127+
if (state.followedSourcesStatus == SourceFilterDataLoadingStatus.success) {
128+
if (state.followedSources.isEmpty) {
129+
ScaffoldMessenger.of(context)
130+
..hideCurrentSnackBar()
131+
..showSnackBar(
132+
SnackBar(
133+
content: Text(l10n.noFollowedItemsForFilterSnackbar),
134+
duration: const Duration(seconds: 3),
135+
),
136+
);
137+
}
138+
} else if (state.followedSourcesStatus == SourceFilterDataLoadingStatus.failure) {
139+
ScaffoldMessenger.of(context)
140+
..hideCurrentSnackBar()
141+
..showSnackBar(
142+
SnackBar(
143+
content: Text(state.error?.message ?? l10n.unknownError),
144+
duration: const Duration(seconds: 3),
145+
),
146+
);
147+
}
118148
},
119-
);
120-
}
149+
child: BlocBuilder<SourcesFilterBloc, SourcesFilterState>(
150+
builder: (context, state) {
151+
final bool isLoadingMainList =
152+
state.dataLoadingStatus == SourceFilterDataLoadingStatus.loading &&
153+
state.allAvailableSources.isEmpty;
154+
final bool isLoadingFollowedSources =
155+
state.followedSourcesStatus == SourceFilterDataLoadingStatus.loading;
121156

122-
return Column(
123-
// Removed Padding, handled by children
124-
crossAxisAlignment: CrossAxisAlignment.start,
125-
children: [
126-
_buildCountryCapsules(context, state, l10n, textTheme),
127-
const SizedBox(height: AppSpacing.md),
128-
_buildSourceTypeCapsules(context, state, l10n, textTheme),
129-
const SizedBox(height: AppSpacing.md),
130-
Padding(
131-
padding: const EdgeInsets.symmetric(
132-
horizontal: AppSpacing.paddingMedium,
133-
),
134-
child: Text(
135-
l10n.headlinesFeedFilterSourceLabel,
136-
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
137-
),
157+
if (isLoadingMainList) {
158+
return LoadingStateWidget(
159+
icon: Icons.source_outlined,
160+
headline: l10n.sourceFilterLoadingHeadline,
161+
subheadline: l10n.sourceFilterLoadingSubheadline,
162+
);
163+
}
164+
if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.failure &&
165+
state.allAvailableSources.isEmpty) {
166+
return FailureStateWidget(
167+
exception: state.error ??
168+
const UnknownException('Failed to load source filter data.'),
169+
onRetry: () {
170+
context.read<SourcesFilterBloc>().add(const LoadSourceFilterData());
171+
},
172+
);
173+
}
174+
175+
return Stack(
176+
children: [
177+
Column(
178+
crossAxisAlignment: CrossAxisAlignment.start,
179+
children: [
180+
_buildCountryCapsules(context, state, l10n, textTheme),
181+
const SizedBox(height: AppSpacing.md),
182+
_buildSourceTypeCapsules(context, state, l10n, textTheme),
183+
const SizedBox(height: AppSpacing.md),
184+
Padding(
185+
padding: const EdgeInsets.symmetric(
186+
horizontal: AppSpacing.paddingMedium,
187+
),
188+
child: Text(
189+
l10n.headlinesFeedFilterSourceLabel,
190+
style: textTheme.titleMedium
191+
?.copyWith(fontWeight: FontWeight.bold),
192+
),
193+
),
194+
const SizedBox(height: AppSpacing.sm),
195+
Expanded(
196+
child: _buildSourcesList(context, state, l10n, textTheme),
197+
),
198+
],
199+
),
200+
// Show loading overlay if followed sources are being fetched
201+
if (isLoadingFollowedSources)
202+
Positioned.fill(
203+
child: Container(
204+
color: Colors.black54, // Semi-transparent overlay
205+
child: Center(
206+
child: Column(
207+
mainAxisAlignment: MainAxisAlignment.center,
208+
children: [
209+
const CircularProgressIndicator(),
210+
const SizedBox(height: AppSpacing.md),
211+
Text(
212+
l10n.headlinesFeedLoadingHeadline,
213+
style: theme.textTheme.titleMedium?.copyWith(
214+
color: Colors.white,
215+
),
216+
),
217+
],
218+
),
219+
),
220+
),
221+
),
222+
],
223+
);
224+
},
138225
),
139-
const SizedBox(height: AppSpacing.sm),
140-
Expanded(child: _buildSourcesList(context, state, l10n, textTheme)),
141-
],
226+
),
142227
);
143228
}
144229

@@ -153,7 +238,6 @@ class _SourceFilterView extends StatelessWidget {
153238
horizontal: AppSpacing.paddingMedium,
154239
).copyWith(top: AppSpacing.md),
155240
child: Column(
156-
// Use Column for label and then list
157241
crossAxisAlignment: CrossAxisAlignment.start,
158242
children: [
159243
Text(
@@ -176,8 +260,8 @@ class _SourceFilterView extends StatelessWidget {
176260
selected: state.selectedCountryIsoCodes.isEmpty,
177261
onSelected: (_) {
178262
context.read<SourcesFilterBloc>().add(
179-
const CountryCapsuleToggled(''),
180-
);
263+
const CountryCapsuleToggled(''),
264+
);
181265
},
182266
);
183267
}
@@ -196,8 +280,8 @@ class _SourceFilterView extends StatelessWidget {
196280
),
197281
onSelected: (_) {
198282
context.read<SourcesFilterBloc>().add(
199-
CountryCapsuleToggled(country.isoCode),
200-
);
283+
CountryCapsuleToggled(country.isoCode),
284+
);
201285
},
202286
);
203287
},
@@ -217,7 +301,6 @@ class _SourceFilterView extends StatelessWidget {
217301
return Padding(
218302
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.paddingMedium),
219303
child: Column(
220-
// Use Column for label and then list
221304
crossAxisAlignment: CrossAxisAlignment.start,
222305
children: [
223306
Text(
@@ -240,8 +323,8 @@ class _SourceFilterView extends StatelessWidget {
240323
selected: state.selectedSourceTypes.isEmpty,
241324
onSelected: (_) {
242325
context.read<SourcesFilterBloc>().add(
243-
const AllSourceTypesCapsuleToggled(),
244-
);
326+
const AllSourceTypesCapsuleToggled(),
327+
);
245328
},
246329
);
247330
}
@@ -252,8 +335,8 @@ class _SourceFilterView extends StatelessWidget {
252335
selected: state.selectedSourceTypes.contains(sourceType),
253336
onSelected: (_) {
254337
context.read<SourcesFilterBloc>().add(
255-
SourceTypeCapsuleToggled(sourceType),
256-
);
338+
SourceTypeCapsuleToggled(sourceType),
339+
);
257340
},
258341
);
259342
},
@@ -272,14 +355,12 @@ class _SourceFilterView extends StatelessWidget {
272355
) {
273356
if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.loading &&
274357
state.displayableSources.isEmpty) {
275-
// Added check for displayableSources
276358
return const Center(child: CircularProgressIndicator());
277359
}
278360
if (state.dataLoadingStatus == SourceFilterDataLoadingStatus.failure &&
279361
state.displayableSources.isEmpty) {
280362
return FailureStateWidget(
281-
exception:
282-
state.error ??
363+
exception: state.error ??
283364
const UnknownException('Failed to load displayable sources.'),
284365
onRetry: () {
285366
context.read<SourcesFilterBloc>().add(const LoadSourceFilterData());
@@ -288,7 +369,6 @@ class _SourceFilterView extends StatelessWidget {
288369
}
289370
if (state.displayableSources.isEmpty &&
290371
state.dataLoadingStatus != SourceFilterDataLoadingStatus.loading) {
291-
// Avoid showing if still loading
292372
return Center(
293373
child: Padding(
294374
padding: const EdgeInsets.all(AppSpacing.paddingLarge),
@@ -314,8 +394,8 @@ class _SourceFilterView extends StatelessWidget {
314394
onChanged: (bool? value) {
315395
if (value != null) {
316396
context.read<SourcesFilterBloc>().add(
317-
SourceCheckboxToggled(source.id, value),
318-
);
397+
SourceCheckboxToggled(source.id, value),
398+
);
319399
}
320400
},
321401
controlAffinity: ListTileControlAffinity.leading,

0 commit comments

Comments
 (0)