diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 9d79f0d..3b6b231 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -10,6 +10,13 @@ class AppRouter extends RootStackRouter { page: DashboardRoute.page, initial: true, children: [ + AutoRoute( + page: FavoriteRoute.page, + children: [ + AutoRoute(page: FavoriteListRoute.page), + AutoRoute(page: BookDetailsRoute.page), + ], + ), AutoRoute( page: HomeRoute.page, children: [AutoRoute(page: ReadingListRoute.page)], diff --git a/lib/core/utils/strings.dart b/lib/core/utils/strings.dart index 0be5186..f6aa88c 100644 --- a/lib/core/utils/strings.dart +++ b/lib/core/utils/strings.dart @@ -1,6 +1,8 @@ const home = 'Home'; const search = 'Search'; const objectives = 'Objectives'; +const favorite = 'Favorite'; +const favoriteList = 'Favorite List'; const searchBooks = 'Search Books'; const startReading = 'Start Reading'; const percent = '%'; diff --git a/lib/layers/data/api/storage_client.dart b/lib/layers/data/api/storage_client.dart index b41d43e..087b8ef 100644 --- a/lib/layers/data/api/storage_client.dart +++ b/lib/layers/data/api/storage_client.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:injectable/injectable.dart'; import 'package:mibook/layers/data/api/custom_errors.dart'; import 'package:mibook/layers/data/models/book_list_data.dart'; @@ -99,8 +100,10 @@ class StorageClient implements IStorageClient { final file = await _getLocalFile(_favoriteBooks); List currentList = await getFavoriteBooks(); - currentList.add(book); + currentList.removeWhere((e) => e.id == book.id); + if (isFavorite) currentList.add(book); final jsonString = jsonEncode(currentList.map((e) => e.toJson()).toList()); + debugPrint('Favorite books JSON: $jsonString'); await file.writeAsString(jsonString); // 2. update favorite status map diff --git a/lib/layers/data/models/book_list_data.dart b/lib/layers/data/models/book_list_data.dart index ac3cda0..5300156 100644 --- a/lib/layers/data/models/book_list_data.dart +++ b/lib/layers/data/models/book_list_data.dart @@ -81,6 +81,9 @@ class BookItem { title: domain.title, authors: domain.authors, pageCount: domain.pageCount, + imageLinks: ImageLinks( + thumbnail: domain.thumbnail, + ), ), ); } diff --git a/lib/layers/domain/models/feature.dart b/lib/layers/domain/models/feature.dart index 7f3569b..43e731b 100644 --- a/lib/layers/domain/models/feature.dart +++ b/lib/layers/domain/models/feature.dart @@ -1 +1 @@ -enum Feature { readingList, search, objectives } +enum Feature { readingList, search, favorite, objectives } diff --git a/lib/layers/presentation/navigation/dashboard/dashboard_page.dart b/lib/layers/presentation/navigation/dashboard/dashboard_page.dart index c781de9..3bdd3f0 100644 --- a/lib/layers/presentation/navigation/dashboard/dashboard_page.dart +++ b/lib/layers/presentation/navigation/dashboard/dashboard_page.dart @@ -11,7 +11,12 @@ class DashboardPage extends StatelessWidget { @override Widget build(BuildContext context) { return AutoTabsRouter( - routes: [ReadingListRoute(), BookSearchRoute(), CurrentObjectiveRoute()], + routes: [ + ReadingListRoute(), + BookSearchRoute(), + FavoriteListRoute(), + CurrentObjectiveRoute(), + ], transitionBuilder: (context, child, animation) => FadeTransition(opacity: animation, child: child), builder: (context, child) { @@ -26,6 +31,7 @@ class DashboardPage extends StatelessWidget { featureList: [ Feature.readingList, Feature.search, + Feature.favorite, Feature.objectives, ], onSelectIndex: router.setActiveIndex, diff --git a/lib/layers/presentation/navigation/dashboard/feature.dart b/lib/layers/presentation/navigation/dashboard/feature.dart index 64cb9e1..bfdf385 100644 --- a/lib/layers/presentation/navigation/dashboard/feature.dart +++ b/lib/layers/presentation/navigation/dashboard/feature.dart @@ -11,6 +11,8 @@ extension FeatureUtils on Feature { return strings.search; case Feature.objectives: return strings.objectives; + case Feature.favorite: + return strings.favorite; } } @@ -22,6 +24,8 @@ extension FeatureUtils on Feature { return Icons.search_outlined; case Feature.objectives: return Icons.book; + case Feature.favorite: + return Icons.favorite; } } } diff --git a/lib/layers/presentation/navigation/tabs/favorite_tab.dart b/lib/layers/presentation/navigation/tabs/favorite_tab.dart new file mode 100644 index 0000000..7ee5879 --- /dev/null +++ b/lib/layers/presentation/navigation/tabs/favorite_tab.dart @@ -0,0 +1,12 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/widgets.dart'; + +@RoutePage() +class FavoriteTab extends StatelessWidget { + const FavoriteTab({super.key}); + + @override + Widget build(BuildContext context) { + return const AutoRouter(); + } +} diff --git a/lib/layers/presentation/screens/favoritelist/favorite_item_ui.dart b/lib/layers/presentation/screens/favoritelist/favorite_item_ui.dart index 336b336..be29aef 100644 --- a/lib/layers/presentation/screens/favoritelist/favorite_item_ui.dart +++ b/lib/layers/presentation/screens/favoritelist/favorite_item_ui.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:mibook/layers/domain/models/book_list_domain.dart'; part 'favorite_item_ui.freezed.dart'; @freezed @@ -14,4 +15,13 @@ class FavoriteItemUI with _$FavoriteItemUI { @Default('') description, thumbnail, }) = _FavoriteItemUI; + + BookDomain get toDomain => BookDomain( + id: id, + kind: kind, + title: title, + authors: authors.isNotEmpty ? authors.split(', ') : [], + description: description, + thumbnail: thumbnail, + ); } diff --git a/lib/layers/presentation/screens/favoritelist/favorite_list_event.dart b/lib/layers/presentation/screens/favoritelist/favorite_list_event.dart index 728fa55..445bd7c 100644 --- a/lib/layers/presentation/screens/favoritelist/favorite_list_event.dart +++ b/lib/layers/presentation/screens/favoritelist/favorite_list_event.dart @@ -1,3 +1,11 @@ class FavoriteListEvent {} class DidAppearEvent extends FavoriteListEvent {} + +class DidTapUnfavoriteEvent extends FavoriteListEvent { + final String bookId; + + DidTapUnfavoriteEvent(this.bookId); +} + +class DidRefreshEvent extends FavoriteListEvent {} diff --git a/lib/layers/presentation/screens/favoritelist/favorite_list_page.dart b/lib/layers/presentation/screens/favoritelist/favorite_list_page.dart new file mode 100644 index 0000000..41ec5a0 --- /dev/null +++ b/lib/layers/presentation/screens/favoritelist/favorite_list_page.dart @@ -0,0 +1,93 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mibook/core/designsystem/organisms/app_nav_bar.dart'; +import 'package:mibook/core/designsystem/organisms/list_item.dart'; +import 'package:mibook/core/di/di.dart'; +import 'package:mibook/core/routes/app_router.gr.dart'; +import 'package:mibook/layers/presentation/screens/favoritelist/favorite_list_event.dart'; +import 'package:mibook/layers/presentation/screens/favoritelist/favorite_list_state.dart'; +import 'package:mibook/layers/presentation/screens/favoritelist/favorite_list_view_model.dart'; + +typedef _BlocBuilder = BlocBuilder; + +@RoutePage() +class FavoriteListPage extends StatelessWidget { + const FavoriteListPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: getIt(), + child: const FavoriteListScaffold(), + ); + } +} + +class FavoriteListScaffold extends StatelessWidget { + const FavoriteListScaffold({super.key}); + + @override + Widget build(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final viewModel = context.read(); + viewModel.add( + DidAppearEvent(), + ); + }); + return Scaffold( + appBar: AppNavBar( + titleText: 'Favorite Books', + textAlignment: AppNavBarTextAlignment.center, + ), + body: _BlocBuilder( + builder: (context, state) { + final viewModel = context.read(); + return RefreshIndicator( + onRefresh: () async { + viewModel.add(DidRefreshEvent()); + }, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: ListView.separated( + itemCount: state.books.length, + separatorBuilder: (context, index) => const SizedBox( + height: 12, + ), + itemBuilder: (context, index) { + final book = state.books[index]; + return Dismissible( + key: Key(book.id), + direction: DismissDirection.endToStart, + onDismissed: (direction) => viewModel.add( + DidTapUnfavoriteEvent(book.id), + ), + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: const Icon(Icons.remove, color: Colors.white), + ), + child: ListItem( + onTap: () => context.router.push( + BookDetailsRoute(id: book.id), + ), + input: BookItemInput( + id: book.id, + kind: book.kind, + title: book.title, + authors: book.authors, + description: book.description, + thumbnail: book.thumbnail, + ), + ), + ); + }, + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/layers/presentation/screens/favoritelist/favorite_list_view_model.dart b/lib/layers/presentation/screens/favoritelist/favorite_list_view_model.dart index eff312e..2ae1f23 100644 --- a/lib/layers/presentation/screens/favoritelist/favorite_list_view_model.dart +++ b/lib/layers/presentation/screens/favoritelist/favorite_list_view_model.dart @@ -1,6 +1,57 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import 'package:mibook/layers/domain/usecases/get_favorite_list.dart'; +import 'package:mibook/layers/domain/usecases/set_favorite.dart'; +import 'package:mibook/layers/presentation/screens/favoritelist/favorite_item_ui.dart'; +import 'package:mibook/layers/presentation/screens/favoritelist/favorite_list_event.dart'; import 'package:mibook/layers/presentation/screens/favoritelist/favorite_list_state.dart'; -class FavoriteListViewModel extends Bloc { - FavoriteListViewModel(super.initialState); +@injectable +class FavoriteListViewModel extends Bloc { + final IGetFavoriteList _getFavoriteList; + final ISetFavorite _setFavorite; + + FavoriteListViewModel( + this._getFavoriteList, + this._setFavorite, + ) : super(FavoriteListState.initial()) { + on((event, emit) async { + final result = await _loadFavoriteBooks(); + debugPrint('result = ${result.books.map((e) => e.thumbnail).toList()}'); + emit(result); + }); + on((event, emit) async { + final result = await _unfavoriteBook(event.bookId); + emit(result); + }); + on((event, emit) async { + final result = await _loadFavoriteBooks(); + debugPrint('result = ${result.books.map((e) => e.thumbnail).toList()}'); + emit(result); + }); + } + + Future _loadFavoriteBooks() async { + final favoriteBooks = await _getFavoriteList(); + final favoriteItemsUI = favoriteBooks + .map( + (elem) => FavoriteItemUI( + id: elem.id, + kind: elem.kind, + title: elem.title, + authors: (elem.authors).join(', '), + description: elem.description ?? '', + thumbnail: elem.thumbnail, + ), + ) + .toList(); + return state.copyWith(books: favoriteItemsUI); + } + + Future _unfavoriteBook(String bookId) async { + final book = state.books.firstWhere((book) => book.id == bookId); + await _setFavorite(book.toDomain, false); + return _loadFavoriteBooks(); + } } diff --git a/test/layers/domain/fakes/fake_favorite_ui.dart b/test/layers/domain/fakes/fake_favorite_ui.dart new file mode 100644 index 0000000..3722766 --- /dev/null +++ b/test/layers/domain/fakes/fake_favorite_ui.dart @@ -0,0 +1,10 @@ +import 'package:mibook/layers/presentation/screens/favoritelist/favorite_item_ui.dart'; + +final fakeFavoriteUI = FavoriteItemUI( + id: 'id', + kind: 'kind', + title: 'title', + authors: '', + description: 'description', + thumbnail: 'thumbnail', +); diff --git a/test/layers/presentation/viewmodel/favorite_list_view_model_test.dart b/test/layers/presentation/viewmodel/favorite_list_view_model_test.dart new file mode 100644 index 0000000..ecabe86 --- /dev/null +++ b/test/layers/presentation/viewmodel/favorite_list_view_model_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mibook/layers/domain/usecases/get_favorite_list.dart'; +import 'package:mibook/layers/domain/usecases/set_favorite.dart'; +import 'package:mibook/layers/presentation/screens/favoritelist/favorite_list_event.dart'; +import 'package:mibook/layers/presentation/screens/favoritelist/favorite_list_state.dart'; +import 'package:mibook/layers/presentation/screens/favoritelist/favorite_list_view_model.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import '../../domain/fakes/fake_book_domain.dart'; +import '../../domain/fakes/fake_favorite_ui.dart'; +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +import 'favorite_list_view_model_test.mocks.dart'; + +void main() { + late MockIGetFavoriteList mockGetFavoriteList; + late MockISetFavorite mockSetFavorite; + late FavoriteListViewModel sut; + + setUp() { + mockGetFavoriteList = MockIGetFavoriteList(); + mockSetFavorite = MockISetFavorite(); + sut = FavoriteListViewModel( + mockGetFavoriteList, + mockSetFavorite, + ); + } + + group('test FavoriteListViewModel', () { + setUp(); + + test('DidAppearEvent', () async { + when( + mockGetFavoriteList(), + ).thenAnswer((_) async => [fakeBookDomain]); + sut.add(DidAppearEvent()); + await expectLater( + sut.stream, + emits( + predicate( + (state) => + state.books.length == 1 && + state.books.first.id == fakeFavoriteUI.id, + ), + ), + ); + verify(mockGetFavoriteList()).called(1); + }); + }); +}