Skip to content

Commit 4e359b3

Browse files
committed
feat(account): add page for following countries
- Implement AddCountryToFollowPage widget - Use AvailableCountriesBloc to fetch and display available countries - Integrate with AccountBloc to manage followed countries - Handle loading, error, and empty states - Display country flags with network image handling - Add follow/unfollow functionality with visual feedback
1 parent 93e81f1 commit 4e359b3

File tree

1 file changed

+176
-0
lines changed

1 file changed

+176
-0
lines changed
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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) =>
106+
Icon(
107+
Icons.flag_outlined,
108+
color: colorScheme.onSurfaceVariant,
109+
size: AppSpacing.lg,
110+
),
111+
loadingBuilder: (
112+
context,
113+
child,
114+
loadingProgress,
115+
) {
116+
if (loadingProgress == null) {
117+
return child;
118+
}
119+
return Center(
120+
child: CircularProgressIndicator(
121+
strokeWidth: 2,
122+
value: loadingProgress
123+
.expectedTotalBytes !=
124+
null
125+
? loadingProgress
126+
.cumulativeBytesLoaded /
127+
loadingProgress
128+
.expectedTotalBytes!
129+
: null,
130+
),
131+
);
132+
},
133+
),
134+
)
135+
: Icon(
136+
Icons.flag_outlined,
137+
color: colorScheme.onSurfaceVariant,
138+
size: AppSpacing.lg,
139+
),
140+
),
141+
title: Text(country.name, style: textTheme.titleMedium),
142+
trailing: IconButton(
143+
icon: isFollowed
144+
? Icon(
145+
Icons.check_circle,
146+
color: colorScheme.primary,
147+
)
148+
: Icon(
149+
Icons.add_circle_outline,
150+
color: colorScheme.onSurfaceVariant,
151+
),
152+
tooltip: isFollowed
153+
? l10n.unfollowCountryTooltip(country.name)
154+
: l10n.followCountryTooltip(country.name),
155+
onPressed: () {
156+
context.read<AccountBloc>().add(
157+
AccountFollowCountryToggled(country: country),
158+
);
159+
},
160+
),
161+
contentPadding: const EdgeInsets.symmetric(
162+
horizontal: AppSpacing.paddingMedium,
163+
vertical: AppSpacing.xs,
164+
),
165+
),
166+
);
167+
},
168+
);
169+
},
170+
);
171+
},
172+
),
173+
),
174+
);
175+
}
176+
}

0 commit comments

Comments
 (0)