Skip to content

Commit cd8a593

Browse files
authored
Merge pull request #97 from flutter-news-app-full-source-code/Integrate-Country-into-Account-Feature
Integrate country into account feature
2 parents 4d96739 + d5036db commit cd8a593

19 files changed

+650
-28
lines changed

lib/account/bloc/account_bloc.dart

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class AccountBloc extends Bloc<AccountEvent, AccountState> {
3737
on<AccountSaveHeadlineToggled>(_onAccountSaveHeadlineToggled);
3838
on<AccountFollowTopicToggled>(_onAccountFollowTopicToggled);
3939
on<AccountFollowSourceToggled>(_onAccountFollowSourceToggled);
40+
on<AccountFollowCountryToggled>(_onAccountFollowCountryToggled);
4041
on<AccountClearUserPreferences>(_onAccountClearUserPreferences);
4142
}
4243

@@ -190,6 +191,64 @@ class AccountBloc extends Bloc<AccountEvent, AccountState> {
190191
}
191192
}
192193

194+
Future<void> _onAccountFollowCountryToggled(
195+
AccountFollowCountryToggled event,
196+
Emitter<AccountState> emit,
197+
) async {
198+
if (state.user == null || state.preferences == null) return;
199+
emit(state.copyWith(status: AccountStatus.loading));
200+
201+
final currentPrefs = state.preferences!;
202+
final isCurrentlyFollowed = currentPrefs.followedCountries.any(
203+
(c) => c.id == event.country.id,
204+
);
205+
final List<Country> updatedFollowedCountries;
206+
207+
updatedFollowedCountries = isCurrentlyFollowed
208+
? (List.from(currentPrefs.followedCountries)
209+
..removeWhere((c) => c.id == event.country.id))
210+
: (List.from(currentPrefs.followedCountries)..add(event.country));
211+
212+
final updatedPrefs = currentPrefs.copyWith(
213+
followedCountries: updatedFollowedCountries,
214+
);
215+
216+
try {
217+
final sortedPrefs = _sortPreferences(updatedPrefs);
218+
await _userContentPreferencesRepository.update(
219+
id: state.user!.id,
220+
item: sortedPrefs,
221+
userId: state.user!.id,
222+
);
223+
emit(
224+
state.copyWith(
225+
status: AccountStatus.success,
226+
preferences: sortedPrefs,
227+
clearError: true,
228+
),
229+
);
230+
} on HttpException catch (e) {
231+
_logger.severe(
232+
'AccountFollowCountryToggled failed with HttpException: $e',
233+
);
234+
emit(state.copyWith(status: AccountStatus.failure, error: e));
235+
} catch (e, st) {
236+
_logger.severe(
237+
'AccountFollowCountryToggled failed with unexpected error: $e',
238+
e,
239+
st,
240+
);
241+
emit(
242+
state.copyWith(
243+
status: AccountStatus.failure,
244+
error: OperationFailedException(
245+
'Failed to update followed countries: $e',
246+
),
247+
),
248+
);
249+
}
250+
}
251+
193252
Future<void> _onAccountSaveHeadlineToggled(
194253
AccountSaveHeadlineToggled event,
195254
Emitter<AccountState> emit,
@@ -435,10 +494,15 @@ class AccountBloc extends Bloc<AccountEvent, AccountState> {
435494
final sortedSources = List<Source>.from(preferences.followedSources)
436495
..sort((a, b) => a.name.compareTo(b.name));
437496

497+
// Sort followed countries by name ascending
498+
final sortedCountries = List<Country>.from(preferences.followedCountries)
499+
..sort((a, b) => a.name.compareTo(b.name));
500+
438501
return preferences.copyWith(
439502
savedHeadlines: sortedHeadlines,
440503
followedTopics: sortedTopics,
441504
followedSources: sortedSources,
505+
followedCountries: sortedCountries,
442506
);
443507
}
444508

lib/account/bloc/account_event.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,13 @@ class AccountFollowSourceToggled extends AccountEvent {
4949
List<Object> get props => [source];
5050
}
5151

52-
// AccountFollowCountryToggled event correctly removed previously
52+
class AccountFollowCountryToggled extends AccountEvent {
53+
const AccountFollowCountryToggled({required this.country});
54+
final Country country;
55+
56+
@override
57+
List<Object> get props => [country];
58+
}
5359

5460
class AccountClearUserPreferences extends AccountEvent {
5561
const AccountClearUserPreferences({required this.userId});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import 'dart:async';
2+
3+
import 'package:bloc/bloc.dart';
4+
import 'package:core/core.dart';
5+
import 'package:data_repository/data_repository.dart';
6+
import 'package:equatable/equatable.dart';
7+
8+
part 'available_countries_event.dart';
9+
part 'available_countries_state.dart';
10+
11+
class AvailableCountriesBloc
12+
extends Bloc<AvailableCountriesEvent, AvailableCountriesState> {
13+
AvailableCountriesBloc({required DataRepository<Country> countriesRepository})
14+
: _countriesRepository = countriesRepository,
15+
super(const AvailableCountriesState()) {
16+
on<FetchAvailableCountries>(_onFetchAvailableCountries);
17+
}
18+
19+
final DataRepository<Country> _countriesRepository;
20+
21+
Future<void> _onFetchAvailableCountries(
22+
FetchAvailableCountries event,
23+
Emitter<AvailableCountriesState> emit,
24+
) async {
25+
if (state.status == AvailableCountriesStatus.loading ||
26+
state.status == AvailableCountriesStatus.success) {
27+
return;
28+
}
29+
emit(state.copyWith(status: AvailableCountriesStatus.loading));
30+
try {
31+
// TODO(fulleni): Add pagination if necessary for very large datasets.
32+
final response = await _countriesRepository.readAll(
33+
filter: {'hasActiveHeadlines': true},
34+
sort: [const SortOption('name', SortOrder.asc)],
35+
);
36+
emit(
37+
state.copyWith(
38+
status: AvailableCountriesStatus.success,
39+
availableCountries: response.items,
40+
clearError: true,
41+
),
42+
);
43+
} on HttpException catch (e) {
44+
emit(
45+
state.copyWith(
46+
status: AvailableCountriesStatus.failure,
47+
error: e.message,
48+
),
49+
);
50+
} catch (e) {
51+
emit(
52+
state.copyWith(
53+
status: AvailableCountriesStatus.failure,
54+
error: 'An unexpected error occurred while fetching countries.',
55+
),
56+
);
57+
}
58+
}
59+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
part of 'available_countries_bloc.dart';
2+
3+
abstract class AvailableCountriesEvent extends Equatable {
4+
const AvailableCountriesEvent();
5+
6+
@override
7+
List<Object> get props => [];
8+
}
9+
10+
class FetchAvailableCountries extends AvailableCountriesEvent {
11+
const FetchAvailableCountries();
12+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
part of 'available_countries_bloc.dart';
2+
3+
enum AvailableCountriesStatus { initial, loading, success, failure }
4+
5+
class AvailableCountriesState extends Equatable {
6+
const AvailableCountriesState({
7+
this.status = AvailableCountriesStatus.initial,
8+
this.availableCountries = const [],
9+
this.error,
10+
});
11+
12+
final AvailableCountriesStatus status;
13+
final List<Country> availableCountries;
14+
final String? error;
15+
16+
AvailableCountriesState copyWith({
17+
AvailableCountriesStatus? status,
18+
List<Country>? availableCountries,
19+
String? error,
20+
bool clearError = false,
21+
}) {
22+
return AvailableCountriesState(
23+
status: status ?? this.status,
24+
availableCountries: availableCountries ?? this.availableCountries,
25+
error: clearError ? null : error ?? this.error,
26+
);
27+
}
28+
29+
@override
30+
List<Object?> get props => [status, availableCountries, error];
31+
}

lib/account/bloc/available_sources_bloc.dart

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ class AvailableSourcesBloc
3232
}
3333
emit(state.copyWith(status: AvailableSourcesStatus.loading));
3434
try {
35-
// Assuming readAll without parameters fetches all items.
3635
// TODO(fulleni): Add pagination if necessary for very large datasets.
3736
final response = await _sourcesRepository.readAll(
3837
sort: [const SortOption('name', SortOrder.asc)],
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import 'package:core/core.dart';
2+
import 'package:data_repository/data_repository.dart';
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_bloc/flutter_bloc.dart';
5+
import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart';
6+
import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/available_countries_bloc.dart';
7+
import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart';
8+
import 'package:ui_kit/ui_kit.dart';
9+
10+
/// {@template add_country_to_follow_page}
11+
/// A page that allows users to browse and select countries to follow.
12+
/// {@endtemplate}
13+
class AddCountryToFollowPage extends StatelessWidget {
14+
/// {@macro add_country_to_follow_page}
15+
const AddCountryToFollowPage({super.key});
16+
17+
@override
18+
Widget build(BuildContext context) {
19+
final l10n = AppLocalizationsX(context).l10n;
20+
final theme = Theme.of(context);
21+
final textTheme = theme.textTheme;
22+
23+
return BlocProvider(
24+
create: (context) => AvailableCountriesBloc(
25+
countriesRepository: context.read<DataRepository<Country>>(),
26+
)..add(const FetchAvailableCountries()),
27+
child: Scaffold(
28+
appBar: AppBar(
29+
title: Text(l10n.addCountriesPageTitle, style: textTheme.titleLarge),
30+
),
31+
body: BlocBuilder<AvailableCountriesBloc, AvailableCountriesState>(
32+
builder: (context, countriesState) {
33+
if (countriesState.status == AvailableCountriesStatus.loading) {
34+
return LoadingStateWidget(
35+
icon: Icons.flag_outlined,
36+
headline: l10n.countryFilterLoadingHeadline,
37+
subheadline: l10n.pleaseWait,
38+
);
39+
}
40+
if (countriesState.status == AvailableCountriesStatus.failure) {
41+
return FailureStateWidget(
42+
exception: OperationFailedException(
43+
countriesState.error ?? l10n.countryFilterError,
44+
),
45+
onRetry: () => context.read<AvailableCountriesBloc>().add(
46+
const FetchAvailableCountries(),
47+
),
48+
);
49+
}
50+
if (countriesState.availableCountries.isEmpty) {
51+
return InitialStateWidget(
52+
icon: Icons.search_off_outlined,
53+
headline: l10n.countryFilterEmptyHeadline,
54+
subheadline: l10n.countryFilterEmptySubheadline,
55+
);
56+
}
57+
58+
final countries = countriesState.availableCountries;
59+
60+
return BlocBuilder<AccountBloc, AccountState>(
61+
buildWhen: (previous, current) =>
62+
previous.preferences?.followedCountries !=
63+
current.preferences?.followedCountries ||
64+
previous.status != current.status,
65+
builder: (context, accountState) {
66+
final followedCountries =
67+
accountState.preferences?.followedCountries ?? [];
68+
69+
return ListView.builder(
70+
padding: const EdgeInsets.symmetric(
71+
horizontal: AppSpacing.paddingMedium,
72+
vertical: AppSpacing.paddingSmall,
73+
).copyWith(bottom: AppSpacing.xxl),
74+
itemCount: countries.length,
75+
itemBuilder: (context, index) {
76+
final country = countries[index];
77+
final isFollowed = followedCountries.any(
78+
(fc) => fc.id == country.id,
79+
);
80+
final colorScheme = Theme.of(context).colorScheme;
81+
82+
return Card(
83+
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
84+
elevation: 0.5,
85+
shape: RoundedRectangleBorder(
86+
borderRadius: BorderRadius.circular(AppSpacing.sm),
87+
side: BorderSide(
88+
color: colorScheme.outlineVariant.withOpacity(0.3),
89+
),
90+
),
91+
child: ListTile(
92+
leading: SizedBox(
93+
width: AppSpacing.xl + AppSpacing.xs,
94+
height: AppSpacing.xl + AppSpacing.xs,
95+
child:
96+
Uri.tryParse(country.flagUrl)?.isAbsolute == true
97+
? ClipRRect(
98+
borderRadius: BorderRadius.circular(
99+
AppSpacing.xs,
100+
),
101+
child: Image.network(
102+
country.flagUrl,
103+
fit: BoxFit.contain,
104+
errorBuilder:
105+
(context, error, stackTrace) => Icon(
106+
Icons.flag_outlined,
107+
color: colorScheme.onSurfaceVariant,
108+
size: AppSpacing.lg,
109+
),
110+
loadingBuilder:
111+
(context, child, loadingProgress) {
112+
if (loadingProgress == null) {
113+
return child;
114+
}
115+
return Center(
116+
child: CircularProgressIndicator(
117+
strokeWidth: 2,
118+
value:
119+
loadingProgress
120+
.expectedTotalBytes !=
121+
null
122+
? loadingProgress
123+
.cumulativeBytesLoaded /
124+
loadingProgress
125+
.expectedTotalBytes!
126+
: null,
127+
),
128+
);
129+
},
130+
),
131+
)
132+
: Icon(
133+
Icons.flag_outlined,
134+
color: colorScheme.onSurfaceVariant,
135+
size: AppSpacing.lg,
136+
),
137+
),
138+
title: Text(country.name, style: textTheme.titleMedium),
139+
trailing: IconButton(
140+
icon: isFollowed
141+
? Icon(
142+
Icons.check_circle,
143+
color: colorScheme.primary,
144+
)
145+
: Icon(
146+
Icons.add_circle_outline,
147+
color: colorScheme.onSurfaceVariant,
148+
),
149+
tooltip: isFollowed
150+
? l10n.unfollowCountryTooltip(country.name)
151+
: l10n.followCountryTooltip(country.name),
152+
onPressed: () {
153+
context.read<AccountBloc>().add(
154+
AccountFollowCountryToggled(country: country),
155+
);
156+
},
157+
),
158+
contentPadding: const EdgeInsets.symmetric(
159+
horizontal: AppSpacing.paddingMedium,
160+
vertical: AppSpacing.xs,
161+
),
162+
),
163+
);
164+
},
165+
);
166+
},
167+
);
168+
},
169+
),
170+
),
171+
);
172+
}
173+
}

0 commit comments

Comments
 (0)