From 1a863ee7f73e3e1ffcfff88503a679dad0b11342 Mon Sep 17 00:00:00 2001 From: olshab Date: Tue, 17 Dec 2024 12:10:42 +0400 Subject: [PATCH] implemented favourites page added navigation bar renamed "like" to "favourite" made cards list into separate widget --- l10n/app_en.arb | 6 +- l10n/app_ru.arb | 6 +- .../locale/l10n/app_localizations.dart | 18 ++ .../locale/l10n/app_localizations_en.dart | 9 + .../locale/l10n/app_localizations_ru.dart | 9 + lib/main.dart | 9 +- .../favourites_bloc/favourites_bloc.dart | 49 +++++ .../favourites_bloc/favourites_events.dart | 13 ++ .../favourites_bloc/favourites_state.dart | 21 ++ .../favourites_bloc/favourites_state.g.dart | 70 ++++++ .../favourites_page/favourites_page.dart | 123 +++++++++++ lib/presentation/home_page/bloc/bloc.dart | 29 +++ lib/presentation/home_page/bloc/events.dart | 8 + .../home_page/{card.dart => card_crypto.dart} | 20 +- lib/presentation/home_page/cards_list.dart | 87 ++++++++ lib/presentation/home_page/home_page.dart | 204 +++++++++--------- lib/presentation/like_bloc/like_bloc.dart | 39 ---- lib/presentation/like_bloc/like_events.dart | 13 -- lib/presentation/like_bloc/like_state.dart | 14 -- lib/presentation/like_bloc/like_state.g.dart | 56 ----- lib/repositories/api_interface.dart | 8 + lib/repositories/crypto_repository.dart | 40 ++++ lib/repositories/mock_repository.dart | 33 +++ 23 files changed, 639 insertions(+), 245 deletions(-) create mode 100644 lib/presentation/favourites_bloc/favourites_bloc.dart create mode 100644 lib/presentation/favourites_bloc/favourites_events.dart create mode 100644 lib/presentation/favourites_bloc/favourites_state.dart create mode 100644 lib/presentation/favourites_bloc/favourites_state.g.dart create mode 100644 lib/presentation/favourites_page/favourites_page.dart rename lib/presentation/home_page/{card.dart => card_crypto.dart} (90%) create mode 100644 lib/presentation/home_page/cards_list.dart delete mode 100644 lib/presentation/like_bloc/like_bloc.dart delete mode 100644 lib/presentation/like_bloc/like_events.dart delete mode 100644 lib/presentation/like_bloc/like_state.dart delete mode 100644 lib/presentation/like_bloc/like_state.g.dart diff --git a/l10n/app_en.arb b/l10n/app_en.arb index e437606..d294ff6 100644 --- a/l10n/app_en.arb +++ b/l10n/app_en.arb @@ -11,5 +11,9 @@ "coinDataPriceChange": "for the last 24 hours", - "settingsLanguage": "Language" + "settingsLanguage": "Language", + + "navigationHome": "Home", + "navigationFavourites": "Favourites", + "navigationSettings": "Settings" } \ No newline at end of file diff --git a/l10n/app_ru.arb b/l10n/app_ru.arb index 770835c..4956ad9 100644 --- a/l10n/app_ru.arb +++ b/l10n/app_ru.arb @@ -11,5 +11,9 @@ "coinDataPriceChange": "за последние 24 часа", - "settingsLanguage": "Язык" + "settingsLanguage": "Язык", + + "navigationHome": "Главная", + "navigationFavourites": "Избранное", + "navigationSettings": "Настройки" } \ No newline at end of file diff --git a/lib/components/locale/l10n/app_localizations.dart b/lib/components/locale/l10n/app_localizations.dart index bce270b..1a47fae 100644 --- a/lib/components/locale/l10n/app_localizations.dart +++ b/lib/components/locale/l10n/app_localizations.dart @@ -142,6 +142,24 @@ abstract class AppLocale { /// In en, this message translates to: /// **'Language'** String get settingsLanguage; + + /// No description provided for @navigationHome. + /// + /// In en, this message translates to: + /// **'Home'** + String get navigationHome; + + /// No description provided for @navigationFavourites. + /// + /// In en, this message translates to: + /// **'Favourites'** + String get navigationFavourites; + + /// No description provided for @navigationSettings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get navigationSettings; } class _AppLocaleDelegate extends LocalizationsDelegate { diff --git a/lib/components/locale/l10n/app_localizations_en.dart b/lib/components/locale/l10n/app_localizations_en.dart index b70b22d..78bacfb 100644 --- a/lib/components/locale/l10n/app_localizations_en.dart +++ b/lib/components/locale/l10n/app_localizations_en.dart @@ -29,4 +29,13 @@ class AppLocaleEn extends AppLocale { @override String get settingsLanguage => 'Language'; + + @override + String get navigationHome => 'Home'; + + @override + String get navigationFavourites => 'Favourites'; + + @override + String get navigationSettings => 'Settings'; } diff --git a/lib/components/locale/l10n/app_localizations_ru.dart b/lib/components/locale/l10n/app_localizations_ru.dart index 372c21d..d25fc41 100644 --- a/lib/components/locale/l10n/app_localizations_ru.dart +++ b/lib/components/locale/l10n/app_localizations_ru.dart @@ -29,4 +29,13 @@ class AppLocaleRu extends AppLocale { @override String get settingsLanguage => 'Язык'; + + @override + String get navigationHome => 'Главная'; + + @override + String get navigationFavourites => 'Избранное'; + + @override + String get navigationSettings => 'Настройки'; } diff --git a/lib/main.dart b/lib/main.dart index 3d85ec2..1ecff7e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter_android_app/components/extensions/context_x.dart'; +import 'package:flutter_android_app/presentation/favourites_bloc/favourites_bloc.dart'; import 'package:flutter_android_app/presentation/home_page/bloc/bloc.dart'; import 'package:flutter_android_app/presentation/home_page/home_page.dart'; -import 'package:flutter_android_app/presentation/like_bloc/like_bloc.dart'; import 'package:flutter_android_app/presentation/locale_bloc/locale_bloc.dart'; import 'package:flutter_android_app/presentation/locale_bloc/locale_state.dart'; import 'package:flutter_android_app/repositories/crypto_repository.dart'; @@ -21,9 +20,9 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( + return BlocProvider( lazy: false, - create: (context) => LikeBloc(), + create: (context) => FavouritesBloc(), child: BlocProvider( lazy: false, create: (context) => LocaleBloc(Locale(_getLangCode(Platform.localeName))), @@ -44,7 +43,7 @@ class MyApp extends StatelessWidget { child: BlocProvider( lazy: false, create: (context) => HomeBloc(context.read()), - child: const MyHomePage(), + child: const MainScaffold(), ), ), ), diff --git a/lib/presentation/favourites_bloc/favourites_bloc.dart b/lib/presentation/favourites_bloc/favourites_bloc.dart new file mode 100644 index 0000000..aa85601 --- /dev/null +++ b/lib/presentation/favourites_bloc/favourites_bloc.dart @@ -0,0 +1,49 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'favourites_events.dart'; +import 'favourites_state.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class FavouritesBloc extends Bloc { + static const String _likedPrefsKey = 'liked'; + + FavouritesBloc() : super(const FavouritesState(favouritesIds: [])) { + on(_onLoadFavouritesIds); + on(_onChangeFavourite); + } + + Future _onLoadFavouritesIds( + LoadFavouritesEvent event, Emitter emit + ) async { + emit(state.copyWith( + hasFavouritesLoaded: false, + )); + + final prefs = await SharedPreferences.getInstance(); + final data = prefs.getStringList(_likedPrefsKey); + + emit(state.copyWith( + likedIds: data, + hasFavouritesLoaded: true, + )); + } + + Future _onChangeFavourite( + ChangeFavouriteEvent event, Emitter emit + ) async { + final updatedList = List.from(state.favouritesIds ?? []); + + if (updatedList.contains(event.id)) { + updatedList.remove(event.id); + } else { + updatedList.add(event.id); + } + + final prefs = await SharedPreferences.getInstance(); + prefs.setStringList(_likedPrefsKey, updatedList); + + emit(state.copyWith( + likedIds: updatedList, + hasFavouritesLoaded: true, + )); + } +} diff --git a/lib/presentation/favourites_bloc/favourites_events.dart b/lib/presentation/favourites_bloc/favourites_events.dart new file mode 100644 index 0000000..b3e9746 --- /dev/null +++ b/lib/presentation/favourites_bloc/favourites_events.dart @@ -0,0 +1,13 @@ +abstract class FavouritesEvent { + const FavouritesEvent(); +} + +class LoadFavouritesEvent extends FavouritesEvent { + const LoadFavouritesEvent(); +} + +class ChangeFavouriteEvent extends FavouritesEvent { + final String id; + + const ChangeFavouriteEvent(this.id); +} diff --git a/lib/presentation/favourites_bloc/favourites_state.dart b/lib/presentation/favourites_bloc/favourites_state.dart new file mode 100644 index 0000000..c792bf7 --- /dev/null +++ b/lib/presentation/favourites_bloc/favourites_state.dart @@ -0,0 +1,21 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:equatable/equatable.dart'; + +part 'favourites_state.g.dart'; + +@CopyWith() +class FavouritesState extends Equatable { + final List? favouritesIds; + final bool hasFavouritesLoaded; + + const FavouritesState({ + this.favouritesIds, + this.hasFavouritesLoaded = false, + }); + + @override + List get props => [ + favouritesIds, + hasFavouritesLoaded, + ]; +} diff --git a/lib/presentation/favourites_bloc/favourites_state.g.dart b/lib/presentation/favourites_bloc/favourites_state.g.dart new file mode 100644 index 0000000..20969f4 --- /dev/null +++ b/lib/presentation/favourites_bloc/favourites_state.g.dart @@ -0,0 +1,70 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'favourites_state.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$FavouritesStateCWProxy { + FavouritesState likedIds(List? likedIds); + + FavouritesState hasFavouritesLoaded(bool hasFavouritesLoaded); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `FavouritesState(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// FavouritesState(...).copyWith(id: 12, name: "My name") + /// ```` + FavouritesState call({ + List? likedIds, + bool? hasFavouritesLoaded, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfFavouritesState.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfFavouritesState.copyWith.fieldName(...)` +class _$FavouritesStateCWProxyImpl implements _$FavouritesStateCWProxy { + const _$FavouritesStateCWProxyImpl(this._value); + + final FavouritesState _value; + + @override + FavouritesState likedIds(List? likedIds) => this(likedIds: likedIds); + + @override + FavouritesState hasFavouritesLoaded(bool hasFavouritesLoaded) => + this(hasFavouritesLoaded: hasFavouritesLoaded); + + @override + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `FavouritesState(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// FavouritesState(...).copyWith(id: 12, name: "My name") + /// ```` + FavouritesState call({ + Object? likedIds = const $CopyWithPlaceholder(), + Object? hasFavouritesLoaded = const $CopyWithPlaceholder(), + }) { + return FavouritesState( + favouritesIds: likedIds == const $CopyWithPlaceholder() + ? _value.favouritesIds + // ignore: cast_nullable_to_non_nullable + : likedIds as List?, + hasFavouritesLoaded: + hasFavouritesLoaded == const $CopyWithPlaceholder() || + hasFavouritesLoaded == null + ? _value.hasFavouritesLoaded + // ignore: cast_nullable_to_non_nullable + : hasFavouritesLoaded as bool, + ); + } +} + +extension $FavouritesStateCopyWith on FavouritesState { + /// Returns a callable class that can be used as follows: `instanceOfFavouritesState.copyWith(...)` or like so:`instanceOfFavouritesState.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$FavouritesStateCWProxy get copyWith => _$FavouritesStateCWProxyImpl(this); +} diff --git a/lib/presentation/favourites_page/favourites_page.dart b/lib/presentation/favourites_page/favourites_page.dart new file mode 100644 index 0000000..1be1c3d --- /dev/null +++ b/lib/presentation/favourites_page/favourites_page.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_android_app/components/extensions/context_x.dart'; +import 'package:flutter_android_app/presentation/favourites_bloc/favourites_bloc.dart'; +import 'package:flutter_android_app/presentation/favourites_bloc/favourites_events.dart'; +import 'package:flutter_android_app/presentation/favourites_bloc/favourites_state.dart'; +import 'package:flutter_android_app/presentation/home_page/bloc/bloc.dart'; +import 'package:flutter_android_app/presentation/home_page/bloc/events.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../domain/models/card.dart'; +import '../details_page/details_page.dart'; +import '../home_page/bloc/state.dart'; +import '../home_page/cards_list.dart'; + +class FavouritesPage extends StatefulWidget { + const FavouritesPage({super.key}); + + @override + State createState() => _FavouritesPageState(); +} + +class _FavouritesPageState extends State { + final ScrollController scrollController = ScrollController(); + List? favouritesIds; + bool wereIdsPreviouslyLoaded = false; + + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().add(const LoadFavouritesEvent()); + }); + + super.initState(); + } + + @override + void dispose() { + scrollController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state.hasFavouritesLoaded && !wereIdsPreviouslyLoaded) { + wereIdsPreviouslyLoaded = true; + favouritesIds = state.favouritesIds; + context.read().add(HomeLoadFavouritesDataEvent( + ids: favouritesIds, + locale: context.locale, + )); + } + }, + child: Padding( + padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), + child: Column( + children: [ + CardsList( + onListRefresh: _onRefresh, + onCardLiked: _onLike, + onCardTapped: _navToDetails, + onNextPage: _onNextPage, + ), + BlocBuilder( + builder: (context, state) => state.isPaginationLoading + ? const CircularProgressIndicator() + : const SizedBox.shrink(), + ), + ], + ), + ), + ); + } + + Future _onRefresh() { + //context.read().add(HomeLoadFavouritesDataEvent( + // ids: favouritesIds, + // locale: context.locale, + //)); + wereIdsPreviouslyLoaded = false; + context.read().add(const LoadFavouritesEvent()); + return Future.value(null); + } + + void _showSnackBar(BuildContext context, String title, bool isLiked) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + '$title ${isLiked ? context.locale.addedToFavourite : context.locale.removedFromFavourite}', + style: Theme.of(context).textTheme.bodyLarge + ), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + duration: const Duration(seconds: 2), + )); + }); + } + + void _onLike(String? id, String title, bool isLiked) { + if (id != null) { + context.read().add(ChangeFavouriteEvent(id)); + _showSnackBar(context, title, !isLiked); + } + } + + void _navToDetails(CardData data) { + Navigator.push(context, MaterialPageRoute( + builder: (context) => DetailsPage(data)), + ); + } + + void _onNextPage() { + final bloc = context.read(); + if (!bloc.state.isPaginationLoading) { + bloc.add(HomeLoadFavouritesDataEvent( + ids: favouritesIds, + nextPage: bloc.state.data?.nextPage, + locale: context.locale, + )); + } + } +} diff --git a/lib/presentation/home_page/bloc/bloc.dart b/lib/presentation/home_page/bloc/bloc.dart index a41a211..be81a6b 100644 --- a/lib/presentation/home_page/bloc/bloc.dart +++ b/lib/presentation/home_page/bloc/bloc.dart @@ -8,6 +8,7 @@ class HomeBloc extends Bloc { HomeBloc(this.repo) : super(const HomeState()) { on(_onLoadData); + on(_onLoadFavouritesData); } Future _onLoadData(HomeLoadDataEvent event, Emitter emit) async { @@ -37,4 +38,32 @@ class HomeBloc extends Bloc { error: error, )); } + + Future _onLoadFavouritesData(HomeLoadFavouritesDataEvent event, Emitter emit) async { + if (event.nextPage == null) { + emit(state.copyWith(isLoading: true)); + } else { + emit(state.copyWith(isPaginationLoading: true)); + } + + String? error; + + final data = await repo.loadDataWithIds( + ids: event.ids ?? [], + page: event.nextPage ?? 1, + onError: (e) => error = e, + locale: event.locale, + ); + + if (event.nextPage != null) { + data?.data?.insertAll(0, state.data?.data ?? []); + } + + emit(state.copyWith( + isLoading: false, + isPaginationLoading: false, + data: data, + error: error, + )); + } } diff --git a/lib/presentation/home_page/bloc/events.dart b/lib/presentation/home_page/bloc/events.dart index 608bbac..53d7b0b 100644 --- a/lib/presentation/home_page/bloc/events.dart +++ b/lib/presentation/home_page/bloc/events.dart @@ -11,3 +11,11 @@ class HomeLoadDataEvent extends HomeEvent { const HomeLoadDataEvent({this.search, this.nextPage, this.locale}); } + +class HomeLoadFavouritesDataEvent extends HomeEvent { + final List? ids; + final int? nextPage; + final AppLocale? locale; + + const HomeLoadFavouritesDataEvent({this.ids, this.nextPage, this.locale}); +} diff --git a/lib/presentation/home_page/card.dart b/lib/presentation/home_page/card_crypto.dart similarity index 90% rename from lib/presentation/home_page/card.dart rename to lib/presentation/home_page/card_crypto.dart index 44bbb0f..6916d28 100644 --- a/lib/presentation/home_page/card.dart +++ b/lib/presentation/home_page/card_crypto.dart @@ -1,8 +1,10 @@ -part of 'home_page.dart'; +import 'package:flutter/material.dart'; + +import '../../domain/models/card.dart'; typedef OnLikeCallback = void Function(String? id, String title, bool isLiked)?; -class _Card extends StatelessWidget { +class CardCrypto extends StatelessWidget { final String id; final String title; final String? imageUrl; @@ -12,7 +14,7 @@ class _Card extends StatelessWidget { final VoidCallback? onTap; final bool isLiked; - const _Card({ + const CardCrypto({ super.key, required this.id, required this.title, @@ -24,12 +26,12 @@ class _Card extends StatelessWidget { this.isLiked = false, }); - factory _Card.fromData( + factory CardCrypto.fromData( CardData data, { OnLikeCallback onLike, VoidCallback? onTap, bool isLiked = false, - }) => _Card( + }) => CardCrypto( id: data.id, title: data.title, imageUrl: data.imageUrl, @@ -71,7 +73,7 @@ class _Card extends StatelessWidget { ), child: SizedBox( height: double.infinity, - width: 100, + width: 140, child: Image.network( imageUrl ?? '', fit: BoxFit.cover, @@ -106,12 +108,12 @@ class _Card extends StatelessWidget { duration: const Duration(milliseconds: 100), child: isLiked ? const Icon( - Icons.favorite, - color: Colors.redAccent, + Icons.star, + color: Colors.orangeAccent, key: ValueKey(0), ) : const Icon( - Icons.favorite_border, + Icons.star_border, key: ValueKey(1), ), ), diff --git a/lib/presentation/home_page/cards_list.dart b/lib/presentation/home_page/cards_list.dart new file mode 100644 index 0000000..b18348e --- /dev/null +++ b/lib/presentation/home_page/cards_list.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_android_app/domain/models/card.dart'; +import 'package:flutter_android_app/presentation/home_page/card_crypto.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../favourites_bloc/favourites_bloc.dart'; +import '../favourites_bloc/favourites_state.dart'; +import 'bloc/bloc.dart'; +import 'bloc/state.dart'; + +class CardsList extends StatefulWidget { + const CardsList({ + required this.onListRefresh, + this.onCardLiked, + this.onCardTapped, + this.onNextPage, + super.key, + }); + + final Future Function() onListRefresh; + final void Function(String? id, String title, bool isLiked)? onCardLiked; + final void Function(CardData data)? onCardTapped; + final void Function()? onNextPage; + + @override + State createState() => _CardsListState(); +} + +class _CardsListState extends State { + final ScrollController scrollController = ScrollController(); + + @override + void initState() { + scrollController.addListener(_onNextPageListener); + + super.initState(); + } + + @override + void dispose() { + scrollController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => state.error != null + ? Text( + state.error ?? '', + style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: Colors.red), + ) + : state.isLoading + ? const CircularProgressIndicator() + : BlocBuilder( + builder: (context, likeState) => Expanded( + child: RefreshIndicator( + onRefresh: widget.onListRefresh, + child: ListView.builder( + controller: scrollController, + padding: EdgeInsets.zero, + itemCount: state.data?.data?.length ?? 0, + itemBuilder: (context, index) { + final data = state.data?.data?[index]; + return data != null + ? CardCrypto.fromData( + data, + isLiked: likeState.favouritesIds?.contains(data.id) == true, + onLike: widget.onCardLiked, + onTap: () => widget.onCardTapped?.call(data), + ) + : const SizedBox.shrink(); + }, + ), + ), + ), + ) + ); + } + + void _onNextPageListener() { + if (scrollController.offset >= scrollController.position.maxScrollExtent) { + widget.onNextPage?.call(); + } + } +} diff --git a/lib/presentation/home_page/home_page.dart b/lib/presentation/home_page/home_page.dart index 48e0569..a8f7144 100644 --- a/lib/presentation/home_page/home_page.dart +++ b/lib/presentation/home_page/home_page.dart @@ -3,21 +3,27 @@ import 'package:flutter_android_app/components/extensions/context_x.dart'; import 'package:flutter_android_app/components/utils/debounce.dart'; import 'package:flutter_android_app/domain/models/card.dart'; import 'package:flutter_android_app/presentation/details_page/details_page.dart'; +import 'package:flutter_android_app/presentation/favourites_page/favourites_page.dart'; import 'package:flutter_android_app/presentation/home_page/bloc/bloc.dart'; import 'package:flutter_android_app/presentation/home_page/bloc/events.dart'; import 'package:flutter_android_app/presentation/home_page/bloc/state.dart'; -import 'package:flutter_android_app/presentation/like_bloc/like_bloc.dart'; -import 'package:flutter_android_app/presentation/like_bloc/like_state.dart'; +import 'package:flutter_android_app/presentation/home_page/cards_list.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../common/svg_objects.dart'; -import '../like_bloc/like_events.dart'; +import '../favourites_bloc/favourites_bloc.dart'; +import '../favourites_bloc/favourites_events.dart'; import '../settings_page/settings_page.dart'; -part 'card.dart'; +class MainScaffold extends StatefulWidget { + const MainScaffold({super.key}); -class MyHomePage extends StatelessWidget { - const MyHomePage({super.key}); + @override + State createState() => _MainScaffoldState(); +} + +class _MainScaffoldState extends State { + int currentPageIndex = 0; @override Widget build(BuildContext context) { @@ -37,21 +43,36 @@ class MyHomePage extends StatelessWidget { ), ], ), - body: const Body(), + bottomNavigationBar: NavigationBar( + destinations: [ + NavigationDestination(icon: const Icon(Icons.home), label: context.locale.navigationHome), + NavigationDestination(icon: const Icon(Icons.favorite), label: context.locale.navigationFavourites), + NavigationDestination(icon: const Icon(Icons.settings), label: context.locale.navigationSettings), + ], + selectedIndex: currentPageIndex, + onDestinationSelected: (int index) => setState(() { + currentPageIndex = index; + }), + ), + body: [ + const HomePage(), + const FavouritesPage(), + const SettingsPage(), + ][currentPageIndex], ); } } -class Body extends StatefulWidget { - const Body({super.key}); +class HomePage extends StatefulWidget { + const HomePage({super.key}); @override - State createState() => _BodyState(); + State createState() => _HomePageState(); } -class _BodyState extends State { +class _HomePageState extends State { final TextEditingController searchController = TextEditingController(); - final ScrollController scrollController = ScrollController(); + @override void initState() { @@ -59,96 +80,67 @@ class _BodyState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { context.read().add(HomeLoadDataEvent(locale: context.locale)); - context.read().add(const LoadLikesEvent()); + context.read().add(const LoadFavouritesEvent()); }); - scrollController.addListener(_onNextPageListener); - super.initState(); } @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().add(HomeLoadDataEvent(search: search, locale: context.locale))); - }, - leading: const Icon(Icons.search), - trailing: [ - IconButton( - icon: const Icon(Icons.close), - onPressed: () { - searchController.clear(); - context.read().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: 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().add(HomeLoadDataEvent(search: search, locale: context.locale))); + }, + leading: const Icon(Icons.search), + trailing: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + searchController.clear(); + context.read().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), ), ), - ], - ), - BlocBuilder( - builder: (context, state) => state.error != null - ? Text( - state.error ?? '', - style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: Colors.red), - ) - : state.isLoading - ? const CircularProgressIndicator() - : BlocBuilder( - builder: (context, likeState) => Expanded( - child: RefreshIndicator( - onRefresh: _onRefresh, - child: ListView.builder( - controller: scrollController, - padding: EdgeInsets.zero, - itemCount: state.data?.data?.length ?? 0, - itemBuilder: (context, index) { - final data = state.data?.data?[index]; - return data != null - ? _Card.fromData( - data, - isLiked: likeState.likedIds?.contains(data.id) == true, - onLike: _onLike, - onTap: () => _navToDetails(context, data), - ) - : const SizedBox.shrink(); - }, - ), - ), - ), - ) - ), - BlocBuilder( - builder: (context, state) => state.isPaginationLoading - ? const CircularProgressIndicator() - : const SizedBox.shrink(), - ), - ], - ) + ), + ], + ), + CardsList( + onListRefresh: _onRefresh, + onCardLiked: _onLike, + onCardTapped: _navToDetails, + onNextPage: _onNextPage, + ), + BlocBuilder( + builder: (context, state) => state.isPaginationLoading + ? const CircularProgressIndicator() + : const SizedBox.shrink(), + ), + ], + ) ); } @override void dispose() { searchController.dispose(); - scrollController.dispose(); super.dispose(); } @@ -160,40 +152,38 @@ class _BodyState extends State { '$title ${isLiked ? context.locale.addedToFavourite : context.locale.removedFromFavourite}', style: Theme.of(context).textTheme.bodyLarge ), - backgroundColor: Colors.deepPurple.shade200, + backgroundColor: Theme.of(context).colorScheme.inversePrimary, duration: const Duration(seconds: 2), )); }); } - void _navToDetails(BuildContext context, CardData data) { - Navigator.push(context, MaterialPageRoute( - builder: (context) => DetailsPage(data)), - ); - } - Future _onRefresh() { context.read().add(HomeLoadDataEvent(search: searchController.text, locale: context.locale)); return Future.value(null); } - void _onNextPageListener() { - if (scrollController.offset >= scrollController.position.maxScrollExtent) { - final bloc = context.read(); - if (!bloc.state.isPaginationLoading) { - bloc.add(HomeLoadDataEvent( - search: searchController.text, - nextPage: bloc.state.data?.nextPage, - locale: context.locale, - )); - } - } - } - void _onLike(String? id, String title, bool isLiked) { if (id != null) { - context.read().add(ChangeLikeEvent(id)); + context.read().add(ChangeFavouriteEvent(id)); _showSnackBar(context, title, !isLiked); } } + + void _navToDetails(CardData data) { + Navigator.push(context, MaterialPageRoute( + builder: (context) => DetailsPage(data)), + ); + } + + void _onNextPage() { + final bloc = context.read(); + if (!bloc.state.isPaginationLoading) { + bloc.add(HomeLoadDataEvent( + search: searchController.text, + nextPage: bloc.state.data?.nextPage, + locale: context.locale, + )); + } + } } diff --git a/lib/presentation/like_bloc/like_bloc.dart b/lib/presentation/like_bloc/like_bloc.dart deleted file mode 100644 index 13c1f8e..0000000 --- a/lib/presentation/like_bloc/like_bloc.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'like_events.dart'; -import 'like_state.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class LikeBloc extends Bloc { - static const String _likedPrefsKey = 'liked'; - - LikeBloc() : super(const LikeState(likedIds: [])) { - on(_onLoadLikes); - on(_onChangeLike); - } - - Future _onLoadLikes( - LoadLikesEvent event, Emitter emit - ) async { - final prefs = await SharedPreferences.getInstance(); - final data = prefs.getStringList(_likedPrefsKey); - - emit(state.copyWith(likedIds: data)); - } - - Future _onChangeLike( - ChangeLikeEvent event, Emitter emit - ) async { - final updatedList = List.from(state.likedIds ?? []); - - if (updatedList.contains(event.id)) { - updatedList.remove(event.id); - } else { - updatedList.add(event.id); - } - - final prefs = await SharedPreferences.getInstance(); - prefs.setStringList(_likedPrefsKey, updatedList); - - emit(state.copyWith(likedIds: updatedList)); - } -} diff --git a/lib/presentation/like_bloc/like_events.dart b/lib/presentation/like_bloc/like_events.dart deleted file mode 100644 index d0326d8..0000000 --- a/lib/presentation/like_bloc/like_events.dart +++ /dev/null @@ -1,13 +0,0 @@ -abstract class LikeEvent { - const LikeEvent(); -} - -class LoadLikesEvent extends LikeEvent { - const LoadLikesEvent(); -} - -class ChangeLikeEvent extends LikeEvent { - final String id; - - const ChangeLikeEvent(this.id); -} diff --git a/lib/presentation/like_bloc/like_state.dart b/lib/presentation/like_bloc/like_state.dart deleted file mode 100644 index bb9d50a..0000000 --- a/lib/presentation/like_bloc/like_state.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:copy_with_extension/copy_with_extension.dart'; -import 'package:equatable/equatable.dart'; - -part 'like_state.g.dart'; - -@CopyWith() -class LikeState extends Equatable { - final List? likedIds; - - const LikeState({this.likedIds}); - - @override - List get props => [likedIds]; -} diff --git a/lib/presentation/like_bloc/like_state.g.dart b/lib/presentation/like_bloc/like_state.g.dart deleted file mode 100644 index 0888cf2..0000000 --- a/lib/presentation/like_bloc/like_state.g.dart +++ /dev/null @@ -1,56 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'like_state.dart'; - -// ************************************************************************** -// CopyWithGenerator -// ************************************************************************** - -abstract class _$LikeStateCWProxy { - LikeState likedIds(List? likedIds); - - /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `LikeState(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. - /// - /// Usage - /// ```dart - /// LikeState(...).copyWith(id: 12, name: "My name") - /// ```` - LikeState call({ - List? likedIds, - }); -} - -/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfLikeState.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfLikeState.copyWith.fieldName(...)` -class _$LikeStateCWProxyImpl implements _$LikeStateCWProxy { - const _$LikeStateCWProxyImpl(this._value); - - final LikeState _value; - - @override - LikeState likedIds(List? likedIds) => this(likedIds: likedIds); - - @override - - /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `LikeState(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. - /// - /// Usage - /// ```dart - /// LikeState(...).copyWith(id: 12, name: "My name") - /// ```` - LikeState call({ - Object? likedIds = const $CopyWithPlaceholder(), - }) { - return LikeState( - likedIds: likedIds == const $CopyWithPlaceholder() - ? _value.likedIds - // ignore: cast_nullable_to_non_nullable - : likedIds as List?, - ); - } -} - -extension $LikeStateCopyWith on LikeState { - /// Returns a callable class that can be used as follows: `instanceOfLikeState.copyWith(...)` or like so:`instanceOfLikeState.copyWith.fieldName(...)`. - // ignore: library_private_types_in_public_api - _$LikeStateCWProxy get copyWith => _$LikeStateCWProxyImpl(this); -} diff --git a/lib/repositories/api_interface.dart b/lib/repositories/api_interface.dart index 1696f3e..76c4fb2 100644 --- a/lib/repositories/api_interface.dart +++ b/lib/repositories/api_interface.dart @@ -11,4 +11,12 @@ abstract class ApiInterface { int pageSize = 20, AppLocale? locale, }); + + Future loadDataWithIds({ + OnErrorCallback? onError, + List ids, + int page = 1, + int pageSize = 8, + AppLocale? locale, + }); } diff --git a/lib/repositories/crypto_repository.dart b/lib/repositories/crypto_repository.dart index bbd165d..8ebe2df 100644 --- a/lib/repositories/crypto_repository.dart +++ b/lib/repositories/crypto_repository.dart @@ -77,6 +77,46 @@ class CryptoRepository extends ApiInterface { } } + @override + Future loadDataWithIds({ + OnErrorCallback? onError, + List ids = const [], + int page = 1, + int pageSize = 20, + AppLocale? locale, + }) async { + try { + Map queryParams = { + 'x_cg_demo_api_key': _apiKey, + 'vs_currency': _getCurrencyName(locale?.localeName), + 'per_page': pageSize, + 'page': page, + }; + + String idsCommaSeparated = ''; + for (var id in ids) { + idsCommaSeparated += '$id,'; + } + if (ids.isEmpty) { + return HomeData(); + } + queryParams['ids'] = idsCommaSeparated; + + final response = await _dio.get( + '$_baseUrl$_coinsDataUrl', + queryParameters: queryParams, + ); + + final CoinsDto dto = CoinsDto.fromJson(response.data as List); + final HomeData data = dto.toDomain(locale, page); + return data; + + } on DioException catch (e) { + onError?.call(e.error?.toString()); + return null; + } + } + String _getCurrencyName(String? localeName) { if (localeName == null) { return 'usd'; diff --git a/lib/repositories/mock_repository.dart b/lib/repositories/mock_repository.dart index 4cc43cc..55b8e1b 100644 --- a/lib/repositories/mock_repository.dart +++ b/lib/repositories/mock_repository.dart @@ -38,4 +38,37 @@ class MockRepository extends ApiInterface { ), ]); } + + @override + Future loadDataWithIds({ + OnErrorCallback? onError, + List ids = const [], + int page = 1, + int pageSize = 20, + AppLocale? locale, + }) async { + return HomeData(data: [ + CardData( + id: 'bitcoin', + title: 'Bitcoin', + imageUrl: 'https://coin-images.coingecko.com/coins/images/1/large/bitcoin.png?1696501400', + currentPrice: '103233 \$', + priceChange: '+2207.71 \$ for the last 24 hours', + ), + CardData( + id: 'ethereum', + title: 'Ethereum', + imageUrl: 'https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628', + currentPrice: '3900.92 \$', + priceChange: '+58.27 \$ for the last 24 hours', + ), + CardData( + id: 'tether', + title: 'Tether', + imageUrl: 'https://coin-images.coingecko.com/coins/images/325/large/Tether.png?1696501661', + currentPrice: '1.001 \$', + priceChange: '+0.00059798 \$ for the last 24 hours', + ), + ]); + } }