implemented favourites page

added navigation bar
renamed "like" to "favourite"
made cards list into separate widget
This commit is contained in:
ShabOl 2024-12-17 12:10:42 +04:00
parent 04120c4847
commit 1a863ee7f7
23 changed files with 639 additions and 245 deletions

View File

@ -11,5 +11,9 @@
"coinDataPriceChange": "for the last 24 hours",
"settingsLanguage": "Language"
"settingsLanguage": "Language",
"navigationHome": "Home",
"navigationFavourites": "Favourites",
"navigationSettings": "Settings"
}

View File

@ -11,5 +11,9 @@
"coinDataPriceChange": "за последние 24 часа",
"settingsLanguage": "Язык"
"settingsLanguage": "Язык",
"navigationHome": "Главная",
"navigationFavourites": "Избранное",
"navigationSettings": "Настройки"
}

View File

@ -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<AppLocale> {

View File

@ -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';
}

View File

@ -29,4 +29,13 @@ class AppLocaleRu extends AppLocale {
@override
String get settingsLanguage => 'Язык';
@override
String get navigationHome => 'Главная';
@override
String get navigationFavourites => 'Избранное';
@override
String get navigationSettings => 'Настройки';
}

View File

@ -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<LikeBloc>(
return BlocProvider<FavouritesBloc>(
lazy: false,
create: (context) => LikeBloc(),
create: (context) => FavouritesBloc(),
child: BlocProvider<LocaleBloc>(
lazy: false,
create: (context) => LocaleBloc(Locale(_getLangCode(Platform.localeName))),
@ -44,7 +43,7 @@ class MyApp extends StatelessWidget {
child: BlocProvider<HomeBloc>(
lazy: false,
create: (context) => HomeBloc(context.read<CryptoRepository>()),
child: const MyHomePage(),
child: const MainScaffold(),
),
),
),

View File

@ -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<FavouritesEvent, FavouritesState> {
static const String _likedPrefsKey = 'liked';
FavouritesBloc() : super(const FavouritesState(favouritesIds: [])) {
on<LoadFavouritesEvent>(_onLoadFavouritesIds);
on<ChangeFavouriteEvent>(_onChangeFavourite);
}
Future<void> _onLoadFavouritesIds(
LoadFavouritesEvent event, Emitter<FavouritesState> 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<void> _onChangeFavourite(
ChangeFavouriteEvent event, Emitter<FavouritesState> emit
) async {
final updatedList = List<String>.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,
));
}
}

View File

@ -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);
}

View File

@ -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<String>? favouritesIds;
final bool hasFavouritesLoaded;
const FavouritesState({
this.favouritesIds,
this.hasFavouritesLoaded = false,
});
@override
List<Object?> get props => [
favouritesIds,
hasFavouritesLoaded,
];
}

View File

@ -0,0 +1,70 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'favourites_state.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$FavouritesStateCWProxy {
FavouritesState likedIds(List<String>? 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<String>? 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<String>? 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<String>?,
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);
}

View File

@ -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<FavouritesPage> createState() => _FavouritesPageState();
}
class _FavouritesPageState extends State<FavouritesPage> {
final ScrollController scrollController = ScrollController();
List<String>? favouritesIds;
bool wereIdsPreviouslyLoaded = false;
@override
void initState() {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<FavouritesBloc>().add(const LoadFavouritesEvent());
});
super.initState();
}
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<FavouritesBloc, FavouritesState>(
listener: (context, state) {
if (state.hasFavouritesLoaded && !wereIdsPreviouslyLoaded) {
wereIdsPreviouslyLoaded = true;
favouritesIds = state.favouritesIds;
context.read<HomeBloc>().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<HomeBloc, HomeState>(
builder: (context, state) => state.isPaginationLoading
? const CircularProgressIndicator()
: const SizedBox.shrink(),
),
],
),
),
);
}
Future<void> _onRefresh() {
//context.read<HomeBloc>().add(HomeLoadFavouritesDataEvent(
// ids: favouritesIds,
// locale: context.locale,
//));
wereIdsPreviouslyLoaded = false;
context.read<FavouritesBloc>().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<FavouritesBloc>().add(ChangeFavouriteEvent(id));
_showSnackBar(context, title, !isLiked);
}
}
void _navToDetails(CardData data) {
Navigator.push(context, MaterialPageRoute<void>(
builder: (context) => DetailsPage(data)),
);
}
void _onNextPage() {
final bloc = context.read<HomeBloc>();
if (!bloc.state.isPaginationLoading) {
bloc.add(HomeLoadFavouritesDataEvent(
ids: favouritesIds,
nextPage: bloc.state.data?.nextPage,
locale: context.locale,
));
}
}
}

View File

@ -8,6 +8,7 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
HomeBloc(this.repo) : super(const HomeState()) {
on<HomeLoadDataEvent>(_onLoadData);
on<HomeLoadFavouritesDataEvent>(_onLoadFavouritesData);
}
Future<void> _onLoadData(HomeLoadDataEvent event, Emitter<HomeState> emit) async {
@ -37,4 +38,32 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
error: error,
));
}
Future<void> _onLoadFavouritesData(HomeLoadFavouritesDataEvent event, Emitter<HomeState> 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,
));
}
}

View File

@ -11,3 +11,11 @@ class HomeLoadDataEvent extends HomeEvent {
const HomeLoadDataEvent({this.search, this.nextPage, this.locale});
}
class HomeLoadFavouritesDataEvent extends HomeEvent {
final List<String>? ids;
final int? nextPage;
final AppLocale? locale;
const HomeLoadFavouritesDataEvent({this.ids, this.nextPage, this.locale});
}

View File

@ -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),
),
),

View File

@ -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<void> Function() onListRefresh;
final void Function(String? id, String title, bool isLiked)? onCardLiked;
final void Function(CardData data)? onCardTapped;
final void Function()? onNextPage;
@override
State<CardsList> createState() => _CardsListState();
}
class _CardsListState extends State<CardsList> {
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<HomeBloc, HomeState>(
builder: (context, state) => state.error != null
? Text(
state.error ?? '',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: Colors.red),
)
: state.isLoading
? const CircularProgressIndicator()
: BlocBuilder<FavouritesBloc, FavouritesState>(
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();
}
}
}

View File

@ -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<MainScaffold> createState() => _MainScaffoldState();
}
class _MainScaffoldState extends State<MainScaffold> {
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<Body> createState() => _BodyState();
State<HomePage> createState() => _HomePageState();
}
class _BodyState extends State<Body> {
class _HomePageState extends State<HomePage> {
final TextEditingController searchController = TextEditingController();
final ScrollController scrollController = ScrollController();
@override
void initState() {
@ -59,96 +80,67 @@ class _BodyState extends State<Body> {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<HomeBloc>().add(HomeLoadDataEvent(locale: context.locale));
context.read<LikeBloc>().add(const LoadLikesEvent());
context.read<FavouritesBloc>().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<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: 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),
),
),
],
),
BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) => state.error != null
? Text(
state.error ?? '',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: Colors.red),
)
: state.isLoading
? const CircularProgressIndicator()
: BlocBuilder<LikeBloc, LikeState>(
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<HomeBloc, HomeState>(
builder: (context, state) => state.isPaginationLoading
? const CircularProgressIndicator()
: const SizedBox.shrink(),
),
],
)
),
],
),
CardsList(
onListRefresh: _onRefresh,
onCardLiked: _onLike,
onCardTapped: _navToDetails,
onNextPage: _onNextPage,
),
BlocBuilder<HomeBloc, HomeState>(
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<Body> {
'$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<void>(
builder: (context) => DetailsPage(data)),
);
}
Future<void> _onRefresh() {
context.read<HomeBloc>().add(HomeLoadDataEvent(search: searchController.text, locale: context.locale));
return Future.value(null);
}
void _onNextPageListener() {
if (scrollController.offset >= scrollController.position.maxScrollExtent) {
final bloc = context.read<HomeBloc>();
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<LikeBloc>().add(ChangeLikeEvent(id));
context.read<FavouritesBloc>().add(ChangeFavouriteEvent(id));
_showSnackBar(context, title, !isLiked);
}
}
void _navToDetails(CardData data) {
Navigator.push(context, MaterialPageRoute<void>(
builder: (context) => DetailsPage(data)),
);
}
void _onNextPage() {
final bloc = context.read<HomeBloc>();
if (!bloc.state.isPaginationLoading) {
bloc.add(HomeLoadDataEvent(
search: searchController.text,
nextPage: bloc.state.data?.nextPage,
locale: context.locale,
));
}
}
}

View File

@ -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<LikeEvent, LikeState> {
static const String _likedPrefsKey = 'liked';
LikeBloc() : super(const LikeState(likedIds: [])) {
on<LoadLikesEvent>(_onLoadLikes);
on<ChangeLikeEvent>(_onChangeLike);
}
Future<void> _onLoadLikes(
LoadLikesEvent event, Emitter<LikeState> emit
) async {
final prefs = await SharedPreferences.getInstance();
final data = prefs.getStringList(_likedPrefsKey);
emit(state.copyWith(likedIds: data));
}
Future<void> _onChangeLike(
ChangeLikeEvent event, Emitter<LikeState> emit
) async {
final updatedList = List<String>.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));
}
}

View File

@ -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);
}

View File

@ -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<String>? likedIds;
const LikeState({this.likedIds});
@override
List<Object?> get props => [likedIds];
}

View File

@ -1,56 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'like_state.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$LikeStateCWProxy {
LikeState likedIds(List<String>? 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<String>? 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<String>? 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<String>?,
);
}
}
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);
}

View File

@ -11,4 +11,12 @@ abstract class ApiInterface {
int pageSize = 20,
AppLocale? locale,
});
Future<HomeData?> loadDataWithIds({
OnErrorCallback? onError,
List<String> ids,
int page = 1,
int pageSize = 8,
AppLocale? locale,
});
}

View File

@ -77,6 +77,46 @@ class CryptoRepository extends ApiInterface {
}
}
@override
Future<HomeData?> loadDataWithIds({
OnErrorCallback? onError,
List<String> ids = const [],
int page = 1,
int pageSize = 20,
AppLocale? locale,
}) async {
try {
Map<String, dynamic> 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<dynamic>);
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';

View File

@ -38,4 +38,37 @@ class MockRepository extends ApiInterface {
),
]);
}
@override
Future<HomeData?> loadDataWithIds({
OnErrorCallback? onError,
List<String> 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',
),
]);
}
}