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"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:label="Crypto Exchange" android:label="Crypto Exchange"
android:name="${applicationName}" android:name="${applicationName}"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,19 +14,25 @@ class DetailsPage extends StatelessWidget {
title: Text(context.locale.detailsPageAppBarTitle), title: Text(context.locale.detailsPageAppBarTitle),
backgroundColor: Theme.of(context).colorScheme.inversePrimary, backgroundColor: Theme.of(context).colorScheme.inversePrimary,
), ),
body: Column( body: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.only(left: 20, right: 20, top: 30),
children: [ child: Column(
Padding( crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.only(bottom: 16), children: [
child: Image.network(data.imageUrl ?? ''), Center(
), child: Padding(
Padding( padding: const EdgeInsets.only(bottom: 40),
padding: const EdgeInsets.only(bottom: 4), child: Image.network(data.imageUrl ?? ''),
child: Text(data.title, style: Theme.of(context).textTheme.headlineLarge), ),
), ),
Text(data.currentPrice, style: Theme.of(context).textTheme.bodyLarge), 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( child: Padding(
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), padding: EdgeInsets.only(top: 12),
child: Column( child: Column(
children: [ children: [
CardsList( CardsList(
@ -65,7 +65,10 @@ class _FavouritesPageState extends State<FavouritesPage> {
), ),
BlocBuilder<HomeBloc, HomeState>( BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) => state.isPaginationLoading builder: (context, state) => state.isPaginationLoading
? const CircularProgressIndicator() ? const Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(),
)
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
], ],
@ -112,7 +115,7 @@ class _FavouritesPageState extends State<FavouritesPage> {
void _onNextPage() { void _onNextPage() {
final bloc = context.read<HomeBloc>(); final bloc = context.read<HomeBloc>();
if (!bloc.state.isPaginationLoading) { if (!bloc.state.isPaginationLoading && !bloc.state.isAllPagesLoaded) {
bloc.add(HomeLoadFavouritesDataEvent( bloc.add(HomeLoadFavouritesDataEvent(
ids: favouritesIds, ids: favouritesIds,
nextPage: bloc.state.data?.nextPage, 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 { Future<void> _onLoadData(HomeLoadDataEvent event, Emitter<HomeState> emit) async {
const int pageSize = 20;
if (event.nextPage == null) { if (event.nextPage == null) {
emit(state.copyWith(isLoading: true)); emit(state.copyWith(isLoading: true));
} else { } else {
@ -23,10 +25,16 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
final data = await repo.loadData( final data = await repo.loadData(
search: event.search, search: event.search,
page: event.nextPage ?? 1, page: event.nextPage ?? 1,
pageSize: pageSize,
onError: (e) => error = e, onError: (e) => error = e,
locale: event.locale, locale: event.locale,
); );
bool isLastPage = false;
if (data?.data != null && data!.data!.length < pageSize) {
isLastPage = true;
}
if (event.nextPage != null) { if (event.nextPage != null) {
data?.data?.insertAll(0, state.data?.data ?? []); data?.data?.insertAll(0, state.data?.data ?? []);
} }
@ -36,10 +44,13 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
isPaginationLoading: false, isPaginationLoading: false,
data: data, data: data,
error: error, error: error,
isAllPagesLoaded: isLastPage,
)); ));
} }
Future<void> _onLoadFavouritesData(HomeLoadFavouritesDataEvent event, Emitter<HomeState> emit) async { Future<void> _onLoadFavouritesData(HomeLoadFavouritesDataEvent event, Emitter<HomeState> emit) async {
const int pageSize = 10;
if (event.nextPage == null) { if (event.nextPage == null) {
emit(state.copyWith(isLoading: true)); emit(state.copyWith(isLoading: true));
} else { } else {
@ -51,10 +62,16 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
final data = await repo.loadDataWithIds( final data = await repo.loadDataWithIds(
ids: event.ids ?? [], ids: event.ids ?? [],
page: event.nextPage ?? 1, page: event.nextPage ?? 1,
pageSize: pageSize,
onError: (e) => error = e, onError: (e) => error = e,
locale: event.locale, locale: event.locale,
); );
bool isLastPage = false;
if (data?.data != null && data!.data!.length < pageSize) {
isLastPage = true;
}
if (event.nextPage != null) { if (event.nextPage != null) {
data?.data?.insertAll(0, state.data?.data ?? []); data?.data?.insertAll(0, state.data?.data ?? []);
} }
@ -64,6 +81,7 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
isPaginationLoading: false, isPaginationLoading: false,
data: data, data: data,
error: error, error: error,
isAllPagesLoaded: isLastPage,
)); ));
} }
} }

View File

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

View File

@ -54,7 +54,7 @@ class CardCrypto extends StatelessWidget {
borderRadius: BorderRadius.circular(15), borderRadius: BorderRadius.circular(15),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.grey.withOpacity(0.5), color: Colors.black38.withOpacity(0.2),
spreadRadius: 4, spreadRadius: 4,
offset: const Offset(0, 5), offset: const Offset(0, 5),
blurRadius: 6, blurRadius: 6,
@ -81,43 +81,52 @@ class CardCrypto extends StatelessWidget {
), ),
), ),
), ),
Flexible( Expanded(
child: Padding( child: Column(
padding: const EdgeInsets.only(left: 12.0, top: 12, bottom: 12), crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, Expanded(
children: [ child: Padding(
Text( padding: const EdgeInsets.only(left: 16, top: 8),
title, child: Column(
style: Theme.of(context).textTheme.headlineLarge, crossAxisAlignment: CrossAxisAlignment.start,
), children: [
Text( Text(
currentPrice, title,
style: Theme.of(context).textTheme.bodyLarge), 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),
), ),
),
), ),
), 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), style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: Colors.red),
) )
: state.isLoading : state.isLoading
? const CircularProgressIndicator() ? const Center(child: Padding(
padding: EdgeInsets.only(top: 20),
child: CircularProgressIndicator(),
))
: BlocBuilder<FavouritesBloc, FavouritesState>( : BlocBuilder<FavouritesBloc, FavouritesState>(
builder: (context, likeState) => Expanded( builder: (context, likeState) => Expanded(
child: RefreshIndicator( child: RefreshIndicator(

View File

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

View File

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