final improvements

added internet permission in manifest
added unique app bar titles for each section
fixed card layout
fixed card details page layout
added appbar button for toggling dark mode
This commit is contained in:
ShabOl 2024-12-17 14:31:39 +04:00
parent 1a863ee7f7
commit f0a17af845
15 changed files with 213 additions and 132 deletions

View File

@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="Crypto Exchange"
android:name="${applicationName}"

View File

@ -3,6 +3,7 @@
"mainAppBarTitle": "Cryptocurrency Exchange",
"detailsPageAppBarTitle": "Cryptocurrency info",
"favouritesPageAppBarTitle": "Favourites",
"settingsPageAppBarTitle": "Settings",
"searchHint": "Search",

View File

@ -3,6 +3,7 @@
"mainAppBarTitle": "Криптобиржа",
"detailsPageAppBarTitle": "Сведения о валюте",
"favouritesPageAppBarTitle": "Избранное",
"settingsPageAppBarTitle": "Настройки",
"searchHint": "Поиск",
@ -13,7 +14,7 @@
"settingsLanguage": "Язык",
"navigationHome": "Главная",
"navigationFavourites": "Избранное",
"navigationSettings": "Настройки"
"navigationHome": "Главная",
"navigationFavourites": "Избранное",
"navigationSettings": "Настройки"
}

View File

@ -107,6 +107,12 @@ abstract class AppLocale {
/// **'Cryptocurrency info'**
String get detailsPageAppBarTitle;
/// No description provided for @favouritesPageAppBarTitle.
///
/// In en, this message translates to:
/// **'Favourites'**
String get favouritesPageAppBarTitle;
/// No description provided for @settingsPageAppBarTitle.
///
/// In en, this message translates to:

View File

@ -12,6 +12,9 @@ class AppLocaleEn extends AppLocale {
@override
String get detailsPageAppBarTitle => 'Cryptocurrency info';
@override
String get favouritesPageAppBarTitle => 'Favourites';
@override
String get settingsPageAppBarTitle => 'Settings';

View File

@ -12,6 +12,9 @@ class AppLocaleRu extends AppLocale {
@override
String get detailsPageAppBarTitle => 'Сведения о валюте';
@override
String get favouritesPageAppBarTitle => 'Избранное';
@override
String get settingsPageAppBarTitle => 'Настройки';

View File

@ -15,9 +15,16 @@ void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
bool isDarkMode = false;
@override
Widget build(BuildContext context) {
return BlocProvider<FavouritesBloc>(
@ -33,8 +40,12 @@ class MyApp extends StatelessWidget {
localizationsDelegates: AppLocale.localizationsDelegates,
supportedLocales: AppLocale.supportedLocales,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigoAccent),
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigoAccent,
brightness: isDarkMode ? Brightness.dark : Brightness.light,
),
useMaterial3: true,
brightness: isDarkMode ? Brightness.dark : Brightness.light,
),
debugShowCheckedModeBanner: false,
home: RepositoryProvider<CryptoRepository>(
@ -43,7 +54,10 @@ class MyApp extends StatelessWidget {
child: BlocProvider<HomeBloc>(
lazy: false,
create: (context) => HomeBloc(context.read<CryptoRepository>()),
child: const MainScaffold(),
child: MainScaffold(
toggleDarkMode: _toggleDarkMode,
isDarkModeSelected: isDarkMode,
),
),
),
),
@ -52,6 +66,12 @@ class MyApp extends StatelessWidget {
);
}
void _toggleDarkMode() {
setState(() {
isDarkMode = !isDarkMode;
});
}
String _getLangCode(String fullLocaleName) {
int index = fullLocaleName.indexOf('_');
return index != -1 ? fullLocaleName.substring(0, index) : fullLocaleName;

View File

@ -14,19 +14,25 @@ class DetailsPage extends StatelessWidget {
title: Text(context.locale.detailsPageAppBarTitle),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Image.network(data.imageUrl ?? ''),
),
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(data.title, style: Theme.of(context).textTheme.headlineLarge),
),
Text(data.currentPrice, style: Theme.of(context).textTheme.bodyLarge),
],
body: Padding(
padding: const EdgeInsets.only(left: 20, right: 20, top: 30),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Padding(
padding: const EdgeInsets.only(bottom: 40),
child: Image.network(data.imageUrl ?? ''),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(data.title, style: Theme.of(context).textTheme.headlineLarge),
),
Text(data.currentPrice, style: Theme.of(context).textTheme.bodyLarge),
Text(data.priceChange, style: Theme.of(context).textTheme.labelLarge),
],
),
),
);
}

View File

@ -54,7 +54,7 @@ class _FavouritesPageState extends State<FavouritesPage> {
}
},
child: Padding(
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
padding: EdgeInsets.only(top: 12),
child: Column(
children: [
CardsList(
@ -65,7 +65,10 @@ class _FavouritesPageState extends State<FavouritesPage> {
),
BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) => state.isPaginationLoading
? const CircularProgressIndicator()
? const Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(),
)
: const SizedBox.shrink(),
),
],
@ -112,7 +115,7 @@ class _FavouritesPageState extends State<FavouritesPage> {
void _onNextPage() {
final bloc = context.read<HomeBloc>();
if (!bloc.state.isPaginationLoading) {
if (!bloc.state.isPaginationLoading && !bloc.state.isAllPagesLoaded) {
bloc.add(HomeLoadFavouritesDataEvent(
ids: favouritesIds,
nextPage: bloc.state.data?.nextPage,

View File

@ -12,6 +12,8 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
}
Future<void> _onLoadData(HomeLoadDataEvent event, Emitter<HomeState> emit) async {
const int pageSize = 20;
if (event.nextPage == null) {
emit(state.copyWith(isLoading: true));
} else {
@ -23,10 +25,16 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
final data = await repo.loadData(
search: event.search,
page: event.nextPage ?? 1,
pageSize: pageSize,
onError: (e) => error = e,
locale: event.locale,
);
bool isLastPage = false;
if (data?.data != null && data!.data!.length < pageSize) {
isLastPage = true;
}
if (event.nextPage != null) {
data?.data?.insertAll(0, state.data?.data ?? []);
}
@ -36,10 +44,13 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
isPaginationLoading: false,
data: data,
error: error,
isAllPagesLoaded: isLastPage,
));
}
Future<void> _onLoadFavouritesData(HomeLoadFavouritesDataEvent event, Emitter<HomeState> emit) async {
const int pageSize = 10;
if (event.nextPage == null) {
emit(state.copyWith(isLoading: true));
} else {
@ -51,10 +62,16 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
final data = await repo.loadDataWithIds(
ids: event.ids ?? [],
page: event.nextPage ?? 1,
pageSize: pageSize,
onError: (e) => error = e,
locale: event.locale,
);
bool isLastPage = false;
if (data?.data != null && data!.data!.length < pageSize) {
isLastPage = true;
}
if (event.nextPage != null) {
data?.data?.insertAll(0, state.data?.data ?? []);
}
@ -64,6 +81,7 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
isPaginationLoading: false,
data: data,
error: error,
isAllPagesLoaded: isLastPage,
));
}
}

View File

@ -6,12 +6,14 @@ class HomeState extends Equatable {
final bool isLoading;
final bool isPaginationLoading;
final String? error;
final bool isAllPagesLoaded;
const HomeState({
this.data,
this.isLoading = false,
this.isPaginationLoading = false,
this.error,
this.isAllPagesLoaded = false,
});
HomeState copyWith({
@ -19,11 +21,13 @@ class HomeState extends Equatable {
bool? isLoading,
bool? isPaginationLoading,
String? error,
bool? isAllPagesLoaded,
}) => HomeState(
data: data ?? this.data,
isLoading: isLoading ?? this.isLoading,
isPaginationLoading: isPaginationLoading ?? this.isPaginationLoading,
error: error ?? this.error,
isAllPagesLoaded: isAllPagesLoaded ?? this.isAllPagesLoaded,
);
@override
@ -32,5 +36,6 @@ class HomeState extends Equatable {
isLoading,
isPaginationLoading,
error,
isAllPagesLoaded,
];
}

View File

@ -54,7 +54,7 @@ class CardCrypto extends StatelessWidget {
borderRadius: BorderRadius.circular(15),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.5),
color: Colors.black38.withOpacity(0.2),
spreadRadius: 4,
offset: const Offset(0, 5),
blurRadius: 6,
@ -81,43 +81,52 @@ class CardCrypto extends StatelessWidget {
),
),
),
Flexible(
child: Padding(
padding: const EdgeInsets.only(left: 12.0, top: 12, bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineLarge,
),
Text(
currentPrice,
style: Theme.of(context).textTheme.bodyLarge),
],
),
),
),
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 16, 16),
child: GestureDetector(
onTap: () => onLike?.call(id, title, isLiked),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
child: isLiked
? const Icon(
Icons.star,
color: Colors.orangeAccent,
key: ValueKey(0),
)
: const Icon(
Icons.star_border,
key: ValueKey(1),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16, top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineLarge,
),
Text(
currentPrice,
style: Theme.of(context).textTheme.bodyLarge),
],
),
),
),
),
Row(
children: [
const Spacer(),
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 16, 16),
child: GestureDetector(
onTap: () => onLike?.call(id, title, isLiked),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
child: isLiked
? const Icon(
Icons.star,
color: Colors.orangeAccent,
key: ValueKey(0),
)
: const Icon(
Icons.star_border,
key: ValueKey(1),
),
),
),
),
],
)
],
),
),
],

View File

@ -52,7 +52,10 @@ class _CardsListState extends State<CardsList> {
style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: Colors.red),
)
: state.isLoading
? const CircularProgressIndicator()
? const Center(child: Padding(
padding: EdgeInsets.only(top: 20),
child: CircularProgressIndicator(),
))
: BlocBuilder<FavouritesBloc, FavouritesState>(
builder: (context, likeState) => Expanded(
child: RefreshIndicator(

View File

@ -16,7 +16,14 @@ import '../favourites_bloc/favourites_events.dart';
import '../settings_page/settings_page.dart';
class MainScaffold extends StatefulWidget {
const MainScaffold({super.key});
const MainScaffold({
super.key,
this.toggleDarkMode,
required this.isDarkModeSelected,
});
final void Function()? toggleDarkMode;
final bool isDarkModeSelected;
@override
State<MainScaffold> createState() => _MainScaffoldState();
@ -30,15 +37,19 @@ class _MainScaffoldState extends State<MainScaffold> {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(context.locale.mainAppBarTitle),
title: [
Text(context.locale.mainAppBarTitle),
Text(context.locale.favouritesPageAppBarTitle),
Text(context.locale.settingsPageAppBarTitle),
][currentPageIndex],
actions: [
Padding(
padding: const EdgeInsets.only(right: 12.0),
child: IconButton(
icon: const Icon(Icons.settings),
onPressed: () => Navigator.push(context, MaterialPageRoute<void>(
builder: (BuildContext context) => const SettingsPage(),
)),
isSelected: widget.isDarkModeSelected,
onPressed: () => widget.toggleDarkMode?.call(),
icon: const Icon(Icons.wb_sunny_outlined),
selectedIcon: const Icon(Icons.brightness_2_outlined),
),
),
],
@ -73,7 +84,6 @@ class HomePage extends StatefulWidget {
class _HomePageState extends State<HomePage> {
final TextEditingController searchController = TextEditingController();
@override
void initState() {
SvgObjects.init();
@ -86,41 +96,43 @@ class _HomePageState extends State<HomePage> {
super.initState();
}
@override
void dispose() {
searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
child: Column(
children: [
Row(
children: [
Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.all(12),
child: SearchBar(
controller: searchController,
onChanged: (search) {
Debounce.run(() => context.read<HomeBloc>().add(HomeLoadDataEvent(search: search, locale: context.locale)));
},
leading: const Icon(Icons.search),
trailing: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
searchController.clear();
context.read<HomeBloc>().add(HomeLoadDataEvent(locale: context.locale));
},
),
],
hintText: context.locale.searchHint,
elevation: const WidgetStatePropertyAll(0.0),
padding: const WidgetStatePropertyAll(EdgeInsets.only(left: 18, right: 10)),
backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.secondaryContainer),
),
Padding(
padding: const EdgeInsets.all(12),
child: SearchBar(
controller: searchController,
onChanged: (search) {
Debounce.run(() => context.read<HomeBloc>().add(HomeLoadDataEvent(search: search, locale: context.locale)));
},
leading: const Icon(Icons.search),
trailing: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
if (searchController.text.isNotEmpty) {
searchController.clear();
context.read<HomeBloc>().add(HomeLoadDataEvent(locale: context.locale));
}
},
),
),
],
],
hintText: context.locale.searchHint,
elevation: const WidgetStatePropertyAll(0.0),
padding: const WidgetStatePropertyAll(EdgeInsets.only(left: 18, right: 10)),
backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.secondaryContainer),
),
),
CardsList(
onListRefresh: _onRefresh,
@ -130,7 +142,10 @@ class _HomePageState extends State<HomePage> {
),
BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) => state.isPaginationLoading
? const CircularProgressIndicator()
? const Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(),
)
: const SizedBox.shrink(),
),
],
@ -138,13 +153,6 @@ class _HomePageState extends State<HomePage> {
);
}
@override
void dispose() {
searchController.dispose();
super.dispose();
}
void _showSnackBar(BuildContext context, String title, bool isLiked) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
@ -178,7 +186,7 @@ class _HomePageState extends State<HomePage> {
void _onNextPage() {
final bloc = context.read<HomeBloc>();
if (!bloc.state.isPaginationLoading) {
if (!bloc.state.isPaginationLoading && !bloc.state.isAllPagesLoaded) {
bloc.add(HomeLoadDataEvent(
search: searchController.text,
nextPage: bloc.state.data?.nextPage,

View File

@ -12,41 +12,35 @@ class SettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.locale.settingsPageAppBarTitle),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Padding(
padding: const EdgeInsets.only(left: 20, right: 16, top: 8),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${context.locale.settingsLanguage}:',
style: Theme.of(context).textTheme.titleMedium,
),
GestureDetector(
onTap: () => context.read<LocaleBloc>().add(const ChangeLocaleEvent()),
child: SizedBox.square(
dimension: 50,
child: Padding(
padding: const EdgeInsets.only(right: 0),
child: BlocBuilder<LocaleBloc, LocaleState>(
return Padding(
padding: const EdgeInsets.only(left: 20, right: 16, top: 8),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${context.locale.settingsLanguage}:',
style: Theme.of(context).textTheme.titleMedium,
),
GestureDetector(
onTap: () => context.read<LocaleBloc>().add(const ChangeLocaleEvent()),
child: SizedBox.square(
dimension: 50,
child: Padding(
padding: const EdgeInsets.only(right: 0),
child: BlocBuilder<LocaleBloc, LocaleState>(
builder: (context, state) {
return state.currentLocale.languageCode == 'ru'
? const SvgRu()
: const SvgUs();
}),
),
),
),
],
),
],
),
),
],
),
],
),
);
}