diff --git a/lib/core/designsystem/atoms/colors.dart b/lib/core/designsystem/atoms/colors.dart index d620da6..5be54a0 100644 --- a/lib/core/designsystem/atoms/colors.dart +++ b/lib/core/designsystem/atoms/colors.dart @@ -7,3 +7,4 @@ const onBackground = Color(0xFFC6E0FA); const background = Color(0xFF3D67FF); const error = Color.fromARGB(255, 196, 8, 8); const primaryText = Color(0xFF110000); +const favorite = Color(0xFFFFBF00); diff --git a/lib/core/designsystem/organisms/app_nav_bar.dart b/lib/core/designsystem/organisms/app_nav_bar.dart index a8928cc..dde449d 100644 --- a/lib/core/designsystem/organisms/app_nav_bar.dart +++ b/lib/core/designsystem/organisms/app_nav_bar.dart @@ -7,6 +7,7 @@ enum AppNavBarTextAlignment { center, start } class AppNavBar extends StatelessWidget implements PreferredSizeWidget { final String titleText; final bool isTitleLoading; + final Widget? trailing; final AppNavBarTextAlignment textAlignment; final Function()? onBack; @@ -16,6 +17,7 @@ class AppNavBar extends StatelessWidget implements PreferredSizeWidget { required this.titleText, this.isTitleLoading = false, this.textAlignment = AppNavBarTextAlignment.center, + this.trailing, }); Widget _layout() { @@ -45,6 +47,14 @@ class AppNavBar extends StatelessWidget implements PreferredSizeWidget { alignment: Alignment.center, child: _title(), ), + if (trailing != null) + Transform.translate( + offset: const Offset(0, -8), + child: Align( + alignment: Alignment.topRight, + child: trailing, + ), + ), ], ); diff --git a/lib/layers/data/api/custom_errors.dart b/lib/layers/data/api/custom_errors.dart index 65f56fc..7874243 100644 --- a/lib/layers/data/api/custom_errors.dart +++ b/lib/layers/data/api/custom_errors.dart @@ -7,3 +7,7 @@ class UnauthorizedError extends DioException { class DuplicatedReadingError extends Error { DuplicatedReadingError(); } + +class DuplicatedBookError extends Error { + DuplicatedBookError(); +} diff --git a/lib/layers/data/api/storage_client.dart b/lib/layers/data/api/storage_client.dart index 86c0857..b41d43e 100644 --- a/lib/layers/data/api/storage_client.dart +++ b/lib/layers/data/api/storage_client.dart @@ -3,24 +3,68 @@ import 'dart:io'; import 'package:injectable/injectable.dart'; import 'package:mibook/layers/data/api/custom_errors.dart'; +import 'package:mibook/layers/data/models/book_list_data.dart'; import 'package:mibook/layers/data/models/reading_data.dart'; import 'package:path_provider/path_provider.dart'; +const _readingList = 'reading_list'; +const _favoriteBooks = 'favorite_books'; +const _isFavoriteBook = 'is_favorite_book'; + abstract class IStorageClient { Future saveReading(ReadingData readingData); Future> getReadingList(); + Future setFavoriteStatus(BookItem book, bool isFavorite); + Future getFavoriteStatus(String bookId); + Future> getFavoriteBooks(); } -@Singleton(as: IStorageClient) +@LazySingleton(as: IStorageClient) class StorageClient implements IStorageClient { Future _getLocalFile(String fileName) async { final directory = await getApplicationDocumentsDirectory(); return File('${directory.path}/$fileName.json'); } + Future> _getListFromFile( + String fileName, + T Function(Map) fromJson, + ) async { + final file = await _getLocalFile(fileName); + final invalidFile = await file.exists() == false; + + if (invalidFile) { + return []; + } + + final jsonString = await file.readAsString(); + + if (jsonString.isEmpty) { + return []; + } + + final List decoded = jsonDecode(jsonString); + return decoded.map((e) => fromJson(e)).toList(); + } + + Future> _parseAsBoolMap(String jsonString) async { + final decoded = jsonDecode(jsonString); + + if (decoded is! Map) { + throw Exception("JSON root is not a map"); + } + + return decoded.map((key, value) { + if (value is! bool) { + throw Exception("Value for '$key' is not a bool"); + } + return MapEntry(key.toString(), value); + }); + } + @override Future saveReading(ReadingData readingData) async { - final file = await _getLocalFile('reading_list'); + final file = await _getLocalFile(_readingList); List currentList = await getReadingList(); if (currentList.any((item) => item.bookId == readingData.bookId)) { @@ -33,19 +77,72 @@ class StorageClient implements IStorageClient { @override Future> getReadingList() async { - final file = await _getLocalFile('reading_list'); + final List readings = await _getListFromFile( + _readingList, + (json) => ReadingData.fromJson(json), + ); + return readings; + } - if (!await file.exists()) { - return []; + @override + Future> getFavoriteBooks() async { + final List favoriteBooks = await _getListFromFile( + _favoriteBooks, + (json) => BookItem.fromJson(json), + ); + return favoriteBooks; + } + + @override + Future setFavoriteStatus(BookItem book, bool isFavorite) async { + // 1. update favorite book list + final file = await _getLocalFile(_favoriteBooks); + List currentList = await getFavoriteBooks(); + + currentList.add(book); + final jsonString = jsonEncode(currentList.map((e) => e.toJson()).toList()); + await file.writeAsString(jsonString); + + // 2. update favorite status map + final isFavoriteFile = await _getLocalFile(_isFavoriteBook); + + Map isFavoriteBookMap = {}; + + // Safely read existing map + if (await isFavoriteFile.exists()) { + final jsonStringIsFavorite = await isFavoriteFile.readAsString(); + + if (jsonStringIsFavorite.isNotEmpty) { + isFavoriteBookMap = await _parseAsBoolMap(jsonStringIsFavorite); + } } - final jsonString = await file.readAsString(); + // Update or insert + isFavoriteBookMap[book.id] = isFavorite; + + // Write back + final isFavoriteJsonString = jsonEncode(isFavoriteBookMap); + await isFavoriteFile.writeAsString(isFavoriteJsonString); + } + + @override + Future getFavoriteStatus(String bookId) async { + final isFavoriteFile = await _getLocalFile(_isFavoriteBook); + + if (!await isFavoriteFile.exists()) { + return false; + } + + final jsonString = await isFavoriteFile.readAsString(); if (jsonString.isEmpty) { - return []; + return false; } - final List decoded = jsonDecode(jsonString); - return decoded.map((e) => ReadingData.fromJson(e)).toList(); + Map isFavoriteBookMap = await _parseAsBoolMap( + jsonString, + ); + + return isFavoriteBookMap[bookId] ?? false; } } diff --git a/lib/layers/data/datasource/favorite_data_source.dart b/lib/layers/data/datasource/favorite_data_source.dart new file mode 100644 index 0000000..b170a1c --- /dev/null +++ b/lib/layers/data/datasource/favorite_data_source.dart @@ -0,0 +1,31 @@ +import 'package:injectable/injectable.dart'; +import 'package:mibook/layers/data/api/storage_client.dart'; +import 'package:mibook/layers/data/models/book_list_data.dart'; + +abstract class IFavoriteDataSource { + Future setFavoriteStatus(BookItem book, bool isFavorite); + Future getFavoriteStatus(String bookId); + Future> getFavoriteBooks(); +} + +@LazySingleton(as: IFavoriteDataSource) +class FavoriteDataSource implements IFavoriteDataSource { + final IStorageClient storageClient; + + FavoriteDataSource(this.storageClient); + + @override + Future setFavoriteStatus(BookItem book, bool isFavorite) async { + await storageClient.setFavoriteStatus(book, isFavorite); + } + + @override + Future getFavoriteStatus(String bookId) async { + return await storageClient.getFavoriteStatus(bookId); + } + + @override + Future> getFavoriteBooks() async { + return await storageClient.getFavoriteBooks(); + } +} diff --git a/lib/layers/data/models/book_list_data.dart b/lib/layers/data/models/book_list_data.dart index 7e48e66..ac3cda0 100644 --- a/lib/layers/data/models/book_list_data.dart +++ b/lib/layers/data/models/book_list_data.dart @@ -39,8 +39,8 @@ class BookItem { final String etag; final String selfLink; final VolumeInfo volumeInfo; - final SaleInfo saleInfo; - final AccessInfo accessInfo; + final SaleInfo? saleInfo; + final AccessInfo? accessInfo; final SearchInfo? searchInfo; BookItem({ @@ -49,8 +49,8 @@ class BookItem { required this.etag, required this.selfLink, required this.volumeInfo, - required this.saleInfo, - required this.accessInfo, + this.saleInfo, + this.accessInfo, this.searchInfo, }); @@ -64,12 +64,26 @@ class BookItem { id: id, kind: kind, title: volumeInfo.title, - authors: volumeInfo.authors, + authors: volumeInfo.authors ?? [], description: volumeInfo.description, thumbnail: volumeInfo.imageLinks?.thumbnail, pageCount: volumeInfo.pageCount ?? 100, ); } + + factory BookItem.fromDomain(BookDomain domain) { + return BookItem( + kind: domain.kind, + id: domain.id, + etag: '', + selfLink: '', + volumeInfo: VolumeInfo( + title: domain.title, + authors: domain.authors, + pageCount: domain.pageCount, + ), + ); + } } @JsonSerializable() diff --git a/lib/layers/data/repository/favorite_repository.dart b/lib/layers/data/repository/favorite_repository.dart new file mode 100644 index 0000000..bb66269 --- /dev/null +++ b/lib/layers/data/repository/favorite_repository.dart @@ -0,0 +1,26 @@ +import 'package:injectable/injectable.dart'; +import 'package:mibook/layers/data/datasource/favorite_data_source.dart'; +import 'package:mibook/layers/data/models/book_list_data.dart'; +import 'package:mibook/layers/domain/models/book_list_domain.dart'; +import 'package:mibook/layers/domain/repository/favorite_repository.dart'; + +@LazySingleton(as: IFavoriteRepository) +class FavoriteRepository implements IFavoriteRepository { + final IFavoriteDataSource _dataSource; + + FavoriteRepository(this._dataSource); + + @override + Future> getFavoriteBooks() async => + _dataSource.getFavoriteBooks().then( + (dataBooks) => dataBooks.map((e) => e.toDomain()).toList(), + ); + + @override + Future getFavoriteStatus(String bookId) async => + _dataSource.getFavoriteStatus(bookId); + + @override + Future setFavoriteStatus(BookDomain book, bool isFavorite) async => + _dataSource.setFavoriteStatus(BookItem.fromDomain(book), isFavorite); +} diff --git a/lib/layers/data/repository/reading_repository.dart b/lib/layers/data/repository/reading_repository.dart index aa59180..e0a9903 100644 --- a/lib/layers/data/repository/reading_repository.dart +++ b/lib/layers/data/repository/reading_repository.dart @@ -4,7 +4,7 @@ import 'package:mibook/layers/data/models/reading_data.dart'; import 'package:mibook/layers/domain/models/reading_domain.dart'; import 'package:mibook/layers/domain/repository/reading_repository.dart'; -@Singleton(as: IReadingRepository) +@LazySingleton(as: IReadingRepository) class ReadingRepository implements IReadingRepository { final IReadingDataSource _dataSource; diff --git a/lib/layers/domain/models/book_list_domain.dart b/lib/layers/domain/models/book_list_domain.dart index c130ea3..679cf42 100644 --- a/lib/layers/domain/models/book_list_domain.dart +++ b/lib/layers/domain/models/book_list_domain.dart @@ -1,29 +1,23 @@ -class BookListDomain { - final int totalItems; - final List books; +import 'package:freezed_annotation/freezed_annotation.dart'; +part 'book_list_domain.freezed.dart'; - BookListDomain({ - required this.totalItems, - required this.books, - }); +@freezed +class BookListDomain with _$BookListDomain { + const factory BookListDomain({ + required int totalItems, + required List books, + }) = _BookListDomain; } -class BookDomain { - final String id; - final String kind; - final String title; - final List? authors; - final String? description; - final String? thumbnail; - final int pageCount; - - BookDomain({ - required this.id, - required this.kind, - required this.title, - required this.authors, - required this.description, - required this.thumbnail, - required this.pageCount, - }); +@freezed +class BookDomain with _$BookDomain { + const factory BookDomain({ + required String id, + required String kind, + required String title, + @Default([]) List authors, + String? description, + String? thumbnail, + @Default(0) int pageCount, + }) = _BookDomain; } diff --git a/lib/layers/domain/repository/favorite_repository.dart b/lib/layers/domain/repository/favorite_repository.dart new file mode 100644 index 0000000..f01c3d9 --- /dev/null +++ b/lib/layers/domain/repository/favorite_repository.dart @@ -0,0 +1,7 @@ +import 'package:mibook/layers/domain/models/book_list_domain.dart'; + +abstract class IFavoriteRepository { + Future setFavoriteStatus(BookDomain book, bool isFavorite); + Future getFavoriteStatus(String bookId); + Future> getFavoriteBooks(); +} diff --git a/lib/layers/domain/usecases/get_favorite.dart b/lib/layers/domain/usecases/get_favorite.dart new file mode 100644 index 0000000..9ac876a --- /dev/null +++ b/lib/layers/domain/usecases/get_favorite.dart @@ -0,0 +1,17 @@ +import 'package:injectable/injectable.dart'; +import 'package:mibook/layers/domain/repository/favorite_repository.dart'; + +abstract class IGetFavorite { + Future call(String id); +} + +@LazySingleton(as: IGetFavorite) +class GetFavorite implements IGetFavorite { + final IFavoriteRepository _favoriteRepository; + + GetFavorite(this._favoriteRepository); + + @override + Future call(String id) async => + await _favoriteRepository.getFavoriteStatus(id); +} diff --git a/lib/layers/domain/usecases/get_favorite_list.dart b/lib/layers/domain/usecases/get_favorite_list.dart new file mode 100644 index 0000000..6119f08 --- /dev/null +++ b/lib/layers/domain/usecases/get_favorite_list.dart @@ -0,0 +1,18 @@ +import 'package:injectable/injectable.dart'; +import 'package:mibook/layers/domain/models/book_list_domain.dart'; +import 'package:mibook/layers/domain/repository/favorite_repository.dart'; + +abstract class IGetFavoriteList { + Future> call(); +} + +@LazySingleton(as: IGetFavoriteList) +class GetFavoriteList implements IGetFavoriteList { + final IFavoriteRepository _favoriteRepository; + + GetFavoriteList(this._favoriteRepository); + + @override + Future> call() async => + await _favoriteRepository.getFavoriteBooks(); +} diff --git a/lib/layers/domain/usecases/search_books.dart b/lib/layers/domain/usecases/search_books.dart index 6a5794f..74cece0 100644 --- a/lib/layers/domain/usecases/search_books.dart +++ b/lib/layers/domain/usecases/search_books.dart @@ -9,7 +9,7 @@ abstract class ISearchBooks { }); } -@Injectable(as: ISearchBooks) +@LazySingleton(as: ISearchBooks) class SearchBooks implements ISearchBooks { final ISearchRepository _repository; diff --git a/lib/layers/domain/usecases/set_favorite.dart b/lib/layers/domain/usecases/set_favorite.dart new file mode 100644 index 0000000..0a93610 --- /dev/null +++ b/lib/layers/domain/usecases/set_favorite.dart @@ -0,0 +1,18 @@ +import 'package:injectable/injectable.dart'; +import 'package:mibook/layers/domain/models/book_list_domain.dart'; +import 'package:mibook/layers/domain/repository/favorite_repository.dart'; + +abstract class ISetFavorite { + Future call(BookDomain book, bool isFavorite); +} + +@LazySingleton(as: ISetFavorite) +class SetFavorite implements ISetFavorite { + final IFavoriteRepository _favoriteRepository; + + SetFavorite(this._favoriteRepository); + + @override + Future call(BookDomain book, bool isFavorite) async => + await _favoriteRepository.setFavoriteStatus(book, isFavorite); +} diff --git a/lib/layers/presentation/screens/bookdetails/book_details_event.dart b/lib/layers/presentation/screens/bookdetails/book_details_event.dart index cc0b1c3..76d50e8 100644 --- a/lib/layers/presentation/screens/bookdetails/book_details_event.dart +++ b/lib/layers/presentation/screens/bookdetails/book_details_event.dart @@ -14,3 +14,5 @@ class DidChangeProgressTextEvent extends BookDetailsEvent { final int progress; DidChangeProgressTextEvent(this.progress); } + +class DidClickFavoriteIconEvent extends BookDetailsEvent {} diff --git a/lib/layers/presentation/screens/bookdetails/book_details_page.dart b/lib/layers/presentation/screens/bookdetails/book_details_page.dart index 4fc0629..9d06c22 100644 --- a/lib/layers/presentation/screens/bookdetails/book_details_page.dart +++ b/lib/layers/presentation/screens/bookdetails/book_details_page.dart @@ -3,6 +3,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_html/flutter_html.dart'; +import 'package:mibook/core/designsystem/atoms/colors.dart'; import 'package:mibook/core/designsystem/molecules/buttons/primary_button.dart'; import 'package:mibook/core/designsystem/organisms/app_nav_bar.dart'; import 'package:mibook/core/designsystem/organisms/list_item.dart'; @@ -37,6 +38,7 @@ class _BookDetailsScaffold extends StatelessWidget { @override Widget build(BuildContext context) { + final viewModel = context.read(); return Scaffold( appBar: PreferredSize( preferredSize: const Size.fromHeight(kToolbarHeight), @@ -46,6 +48,18 @@ class _BookDetailsScaffold extends StatelessWidget { titleText: state.bookDetails?.title ?? 'Loading...', isTitleLoading: state.isLoading, onBack: context.router.maybePop, + trailing: Visibility( + visible: !state.isLoading, + child: IconButton( + onPressed: () { + viewModel.add(DidClickFavoriteIconEvent()); + }, + icon: Icon( + state.isFavorite ? Icons.star : Icons.star_border, + color: state.isFavorite ? favorite : null, + ), + ), + ), ); }, ), diff --git a/lib/layers/presentation/screens/bookdetails/book_details_state.dart b/lib/layers/presentation/screens/bookdetails/book_details_state.dart index 8e9e4c3..67e33f0 100644 --- a/lib/layers/presentation/screens/bookdetails/book_details_state.dart +++ b/lib/layers/presentation/screens/bookdetails/book_details_state.dart @@ -9,6 +9,7 @@ class BookDetailsState with _$BookDetailsState { BookDetailsUI? bookDetails, @Default(false) bool isLoading, @Default(0.0) double bookProgress, + @Default(true) bool isFavorite, }) = _BookDetailsState; const BookDetailsState._(); // allows adding custom getters or methods later diff --git a/lib/layers/presentation/screens/bookdetails/book_details_ui.dart b/lib/layers/presentation/screens/bookdetails/book_details_ui.dart index 97cf405..177ac11 100644 --- a/lib/layers/presentation/screens/bookdetails/book_details_ui.dart +++ b/lib/layers/presentation/screens/bookdetails/book_details_ui.dart @@ -24,10 +24,20 @@ class BookDetailsUI { id: domain.id, kind: domain.kind, title: domain.title, - authors: (domain.authors ?? []).join(', '), + authors: (domain.authors).join(', '), description: domain.description ?? '', thumbnail: domain.thumbnail, pageCount: domain.pageCount, ); } + + BookDomain get toDomain => BookDomain( + id: id, + kind: kind, + title: title, + authors: authors.isNotEmpty ? authors.split(', ') : [], + description: description, + thumbnail: thumbnail, + pageCount: pageCount, + ); } diff --git a/lib/layers/presentation/screens/bookdetails/book_details_view_model.dart b/lib/layers/presentation/screens/bookdetails/book_details_view_model.dart index 84a25d1..44a755e 100644 --- a/lib/layers/presentation/screens/bookdetails/book_details_view_model.dart +++ b/lib/layers/presentation/screens/bookdetails/book_details_view_model.dart @@ -2,6 +2,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; import 'package:mibook/layers/domain/models/reading_domain.dart'; import 'package:mibook/layers/domain/usecases/get_book_details.dart'; +import 'package:mibook/layers/domain/usecases/get_favorite.dart'; +import 'package:mibook/layers/domain/usecases/set_favorite.dart'; import 'package:mibook/layers/domain/usecases/start_reading.dart'; import 'package:mibook/layers/presentation/screens/bookdetails/book_details_event.dart'; import 'package:mibook/layers/presentation/screens/bookdetails/book_details_state.dart'; @@ -11,11 +13,15 @@ import 'package:mibook/layers/presentation/screens/bookdetails/book_details_ui.d class BookDetailsViewModel extends Bloc { final IGetBookDetails _getBookDetails; final IStartReading _startReading; + final ISetFavorite _setFavorite; + final IGetFavorite _getFavorite; String? bookId; BookDetailsViewModel( this._getBookDetails, this._startReading, + this._setFavorite, + this._getFavorite, @factoryParam this.bookId, ) : super(BookDetailsState.initial()) { on((event, emit) async { @@ -26,11 +32,14 @@ class BookDetailsViewModel extends Bloc { ), ); try { - final bookDetails = await loadBookDetails(); + final bookDetails = await _loadBookDetails(); + final isFavorite = await _loadFavoriteStatus(); + emit( state.copyWith( isLoading: false, bookDetails: bookDetails, + isFavorite: isFavorite, ), ); } catch (e) { @@ -38,6 +47,7 @@ class BookDetailsViewModel extends Bloc { state.copyWith( isLoading: false, errorMessage: e.toString(), + isFavorite: false, ), ); } @@ -50,9 +60,13 @@ class BookDetailsViewModel extends Bloc { emit(state.copyWith(bookProgress: progress)); } }); + on((event, emit) { + setFavoriteStatus(isFavorite: !state.isFavorite); + emit(state.copyWith(isFavorite: !state.isFavorite)); + }); } - Future loadBookDetails() async { + Future _loadBookDetails() async { if (bookId != null) { final bookDetails = await _getBookDetails(id: bookId!); return BookDetailsUI.fromDomain(bookDetails); @@ -61,6 +75,14 @@ class BookDetailsViewModel extends Bloc { } } + Future _loadFavoriteStatus() async { + if (bookId != null) { + final isFavorite = await _getFavorite(bookId!); + return isFavorite; + } + return false; + } + Future startReading({required double progress}) async { if (bookId != null) { await _startReading( @@ -73,4 +95,13 @@ class BookDetailsViewModel extends Bloc { ); } } + + Future setFavoriteStatus({required bool isFavorite}) async { + if (state.bookDetails != null) { + await _setFavorite( + state.bookDetails!.toDomain, + isFavorite, + ); + } + } } diff --git a/lib/layers/presentation/screens/booksearch/book_search_view_model.dart b/lib/layers/presentation/screens/booksearch/book_search_view_model.dart index 33265fa..1f4e95c 100644 --- a/lib/layers/presentation/screens/booksearch/book_search_view_model.dart +++ b/lib/layers/presentation/screens/booksearch/book_search_view_model.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:injectable/injectable.dart'; import 'package:mibook/layers/domain/usecases/search_books.dart'; import 'package:mibook/layers/presentation/screens/booksearch/book_search_event.dart'; @@ -13,7 +12,6 @@ class BookSearchViewModel extends Bloc { BookSearchViewModel(this._searchBooks) : super(BookSearchState.initial()) { on((event, emit) async { - debugPrint('DidChangeSearchTextEvent: ${event.text}'); emit(state.copyWith(searchText: event.text)); }); on((event, emit) async { @@ -26,9 +24,7 @@ class BookSearchViewModel extends Bloc { canLoadNextPage: true, ), ); - debugPrint('Click search with text: ${state.searchText}'); final response = await _search(state.searchText); - debugPrint('Response received with $response'); emit( state.copyWith( books: response, @@ -57,7 +53,6 @@ class BookSearchViewModel extends Bloc { ), ); } catch (e) { - debugPrint('Error during pagination: $e'); emit( state.copyWith( isPageLoading: false, diff --git a/lib/layers/presentation/screens/booksearch/book_ui.dart b/lib/layers/presentation/screens/booksearch/book_ui.dart index 53d5c12..07d2916 100644 --- a/lib/layers/presentation/screens/booksearch/book_ui.dart +++ b/lib/layers/presentation/screens/booksearch/book_ui.dart @@ -24,7 +24,7 @@ class BookUI { id: domain.id, kind: domain.kind, title: domain.title, - authors: (domain.authors ?? []).join(', '), + authors: (domain.authors).join(', '), description: domain.description ?? '', thumbnail: domain.thumbnail, pageCount: domain.pageCount, diff --git a/lib/layers/presentation/screens/favoritelist/favorite_item_ui.dart b/lib/layers/presentation/screens/favoritelist/favorite_item_ui.dart new file mode 100644 index 0000000..336b336 --- /dev/null +++ b/lib/layers/presentation/screens/favoritelist/favorite_item_ui.dart @@ -0,0 +1,17 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +part 'favorite_item_ui.freezed.dart'; + +@freezed +class FavoriteItemUI with _$FavoriteItemUI { + const FavoriteItemUI._(); + + const factory FavoriteItemUI({ + @Default('') id, + @Default('') kind, + @Default('') title, + @Default('') authors, + @Default('') description, + thumbnail, + }) = _FavoriteItemUI; +} diff --git a/lib/layers/presentation/screens/favoritelist/favorite_list_event.dart b/lib/layers/presentation/screens/favoritelist/favorite_list_event.dart new file mode 100644 index 0000000..728fa55 --- /dev/null +++ b/lib/layers/presentation/screens/favoritelist/favorite_list_event.dart @@ -0,0 +1,3 @@ +class FavoriteListEvent {} + +class DidAppearEvent extends FavoriteListEvent {} diff --git a/lib/layers/presentation/screens/favoritelist/favorite_list_state.dart b/lib/layers/presentation/screens/favoritelist/favorite_list_state.dart new file mode 100644 index 0000000..ba82c48 --- /dev/null +++ b/lib/layers/presentation/screens/favoritelist/favorite_list_state.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:mibook/layers/presentation/screens/favoritelist/favorite_item_ui.dart'; +part 'favorite_list_state.freezed.dart'; + +@freezed +class FavoriteListState with _$FavoriteListState { + const factory FavoriteListState({ + @Default([]) List books, + }) = _FavoriteListState; + + const FavoriteListState._(); + + factory FavoriteListState.initial() => const FavoriteListState(); +} diff --git a/lib/layers/presentation/screens/favoritelist/favorite_list_view_model.dart b/lib/layers/presentation/screens/favoritelist/favorite_list_view_model.dart new file mode 100644 index 0000000..eff312e --- /dev/null +++ b/lib/layers/presentation/screens/favoritelist/favorite_list_view_model.dart @@ -0,0 +1,6 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mibook/layers/presentation/screens/favoritelist/favorite_list_state.dart'; + +class FavoriteListViewModel extends Bloc { + FavoriteListViewModel(super.initialState); +} diff --git a/lib/layers/presentation/screens/startreading/start_reading_view_model.dart b/lib/layers/presentation/screens/startreading/start_reading_view_model.dart index 72855af..b3174a8 100644 --- a/lib/layers/presentation/screens/startreading/start_reading_view_model.dart +++ b/lib/layers/presentation/screens/startreading/start_reading_view_model.dart @@ -70,7 +70,6 @@ class StartReadingViewModel extends Bloc { await _startReading(reading: reading); return state.copyWith(shouldNavigateBack: true); } catch (_) { - print('Error saving reading for bookId: ${book.id}'); return state.copyWith(shouldShowSavingError: true); } } diff --git a/lib/main.dart b/lib/main.dart index 224f0ec..21cd845 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,7 +4,7 @@ import 'package:mibook/core/di/di.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - await configureDependencies(); + configureDependencies(); runApp(CoreApplication()); } diff --git a/pubspec.lock b/pubspec.lock index 6a545ad..e470d5c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -484,10 +484,10 @@ packages: dependency: transitive description: name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -684,10 +684,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "6.1.0" platform: dependency: transitive description: @@ -1113,10 +1113,10 @@ packages: dependency: transitive description: name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 url: "https://pub.dev" source: hosted - version: "6.6.1" + version: "6.5.0" yaml: dependency: transitive description: diff --git a/test/layers/data/datasource/fake_book_item.dart b/test/layers/data/datasource/fake_book_item.dart new file mode 100644 index 0000000..ec40d41 --- /dev/null +++ b/test/layers/data/datasource/fake_book_item.dart @@ -0,0 +1,13 @@ +import 'package:mibook/layers/data/models/book_list_data.dart'; + +final fakeBookItem = BookItem( + kind: 'fiction', + id: 'id', + etag: 'tag', + selfLink: 'link', + volumeInfo: VolumeInfo( + title: 'Harry Potter', + authors: ['JK Rowling'], + pageCount: 300, + ), +); diff --git a/test/layers/data/datasource/favorite_data_source_test.dart b/test/layers/data/datasource/favorite_data_source_test.dart new file mode 100644 index 0000000..f5eb058 --- /dev/null +++ b/test/layers/data/datasource/favorite_data_source_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mibook/layers/data/api/storage_client.dart'; +import 'package:mibook/layers/data/datasource/favorite_data_source.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'fake_book_item.dart'; +@GenerateNiceMocks([MockSpec()]) +import 'favorite_data_source_test.mocks.dart'; + +void main() { + late MockIStorageClient storageClient; + late FavoriteDataSource sut; + + setUp() { + storageClient = MockIStorageClient(); + sut = FavoriteDataSource(storageClient); + } + + group('FavoriteDataSource', () { + setUp(); + + test('setFavoriteStatus', () async { + await sut.setFavoriteStatus(fakeBookItem, true); + verify(storageClient.setFavoriteStatus(fakeBookItem, true)).called(1); + }); + + test('getFavoriteStatus', () async { + when(storageClient.getFavoriteStatus('id')).thenAnswer((_) async => true); + final result = await sut.getFavoriteStatus('id'); + verify(storageClient.getFavoriteStatus('id')).called(1); + expect(result, true); + }); + + test('getFavoriteBooks', () async { + final fakeBooks = [fakeBookItem]; + when(storageClient.getFavoriteBooks()).thenAnswer((_) async => fakeBooks); + final result = await sut.getFavoriteBooks(); + verify(storageClient.getFavoriteBooks()).called(1); + expect(result, fakeBooks); + }); + }); +} diff --git a/test/layers/data/repository/fake_book_domain.dart b/test/layers/data/repository/fake_book_domain.dart new file mode 100644 index 0000000..64780e6 --- /dev/null +++ b/test/layers/data/repository/fake_book_domain.dart @@ -0,0 +1,9 @@ +import 'package:mibook/layers/domain/models/book_list_domain.dart'; + +final fakeBookDomain = BookDomain( + id: 'id', + kind: 'fiction', + title: 'Harry Potter', + authors: ['JK Rowling'], + pageCount: 300, +); diff --git a/test/layers/data/repository/favorite_repository_test.dart b/test/layers/data/repository/favorite_repository_test.dart new file mode 100644 index 0000000..f5f8563 --- /dev/null +++ b/test/layers/data/repository/favorite_repository_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mibook/layers/data/datasource/favorite_data_source.dart'; +import 'package:mibook/layers/data/repository/favorite_repository.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import '../datasource/fake_book_item.dart'; +import 'fake_book_domain.dart'; +@GenerateNiceMocks([MockSpec()]) +import 'favorite_repository_test.mocks.dart'; + +void main() { + late MockIFavoriteDataSource mockFavoriteDataSource; + late FavoriteRepository sut; + + setUp() { + mockFavoriteDataSource = MockIFavoriteDataSource(); + sut = FavoriteRepository(mockFavoriteDataSource); + } + + group('FavoriteDataSource', () { + setUp(); + + test('getFavoriteBooks', () async { + when( + mockFavoriteDataSource.getFavoriteBooks(), + ).thenAnswer((_) async => [fakeBookItem]); + final result = await sut.getFavoriteBooks(); + verify(mockFavoriteDataSource.getFavoriteBooks()).called(1); + expect(result, [fakeBookDomain]); + }); + + test('getFavoriteStatus', () async { + when( + mockFavoriteDataSource.getFavoriteStatus('id'), + ).thenAnswer((_) async => true); + final result = await sut.getFavoriteStatus('id'); + verify(mockFavoriteDataSource.getFavoriteStatus('id')).called(1); + expect(result, true); + }); + + test('setFavoriteStatus', () async { + await sut.setFavoriteStatus(fakeBookDomain, true); + verify( + mockFavoriteDataSource.setFavoriteStatus( + any, + true, + ), + ).called(1); + }); + }); +} diff --git a/test/layers/domain/get_favorite_list_test.dart b/test/layers/domain/get_favorite_list_test.dart new file mode 100644 index 0000000..1a78b67 --- /dev/null +++ b/test/layers/domain/get_favorite_list_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mibook/layers/domain/usecases/get_favorite_list.dart'; +import 'package:mockito/mockito.dart'; + +import '../data/repository/fake_book_domain.dart'; +import 'set_favorite_test.mocks.dart'; + +void main() { + late MockIFavoriteRepository mockFavoriteRepository; + late GetFavoriteList sut; + + setUp() { + mockFavoriteRepository = MockIFavoriteRepository(); + sut = GetFavoriteList(mockFavoriteRepository); + } + + group('test GetFavoriteList', () { + setUp(); + + test('call', () async { + when( + mockFavoriteRepository.getFavoriteBooks(), + ).thenAnswer((_) async => [fakeBookDomain]); + final result = await sut(); + verify(mockFavoriteRepository.getFavoriteBooks()).called(1); + expect(result, [fakeBookDomain]); + }); + }); +} diff --git a/test/layers/domain/get_favorite_test.dart b/test/layers/domain/get_favorite_test.dart new file mode 100644 index 0000000..3494688 --- /dev/null +++ b/test/layers/domain/get_favorite_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mibook/layers/domain/repository/favorite_repository.dart'; +import 'package:mibook/layers/domain/usecases/get_favorite.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +@GenerateNiceMocks([MockSpec()]) +import 'set_favorite_test.mocks.dart'; + +void main() { + late MockIFavoriteRepository mockFavoriteRepository; + late GetFavorite sut; + + setUp() { + mockFavoriteRepository = MockIFavoriteRepository(); + sut = GetFavorite(mockFavoriteRepository); + } + + group('test GetFavorite', () { + setUp(); + + test('call', () async { + when( + mockFavoriteRepository.getFavoriteStatus('id'), + ).thenAnswer((_) async => true); + final result = await sut('id'); + verify(mockFavoriteRepository.getFavoriteStatus('id')).called(1); + expect(result, true); + }); + }); +} diff --git a/test/layers/domain/set_favorite_test.dart b/test/layers/domain/set_favorite_test.dart new file mode 100644 index 0000000..062a2e9 --- /dev/null +++ b/test/layers/domain/set_favorite_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mibook/layers/domain/repository/favorite_repository.dart'; +import 'package:mibook/layers/domain/usecases/set_favorite.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import '../data/repository/fake_book_domain.dart'; +@GenerateNiceMocks([MockSpec()]) +import 'set_favorite_test.mocks.dart'; + +void main() { + late MockIFavoriteRepository mockFavoriteRepository; + late SetFavorite sut; + + setUp() { + mockFavoriteRepository = MockIFavoriteRepository(); + sut = SetFavorite(mockFavoriteRepository); + } + + group('test SetFavorite', () { + setUp(); + + test('call', () async { + await sut(fakeBookDomain, true); + verify( + mockFavoriteRepository.setFavoriteStatus( + fakeBookDomain, + true, + ), + ).called(1); + }); + }); +} diff --git a/test/layers/presentation/viewmodel/book_details_view_model_test.dart b/test/layers/presentation/viewmodel/book_details_view_model_test.dart new file mode 100644 index 0000000..fdeae50 --- /dev/null +++ b/test/layers/presentation/viewmodel/book_details_view_model_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mibook/layers/domain/usecases/get_book_details.dart'; +import 'package:mibook/layers/domain/usecases/get_favorite.dart'; +import 'package:mibook/layers/domain/usecases/set_favorite.dart'; +import 'package:mibook/layers/domain/usecases/start_reading.dart'; +import 'package:mibook/layers/presentation/screens/bookdetails/book_details_event.dart'; +import 'package:mibook/layers/presentation/screens/bookdetails/book_details_state.dart'; +import 'package:mibook/layers/presentation/screens/bookdetails/book_details_view_model.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import '../../domain/fakes/fake_book_domain.dart'; +@GenerateNiceMocks( + [ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + ], +) +import 'book_details_view_model_test.mocks.dart'; + +void main() { + late MockISetFavorite mockSetFavorite; + late MockIGetFavorite mockGetFavorite; + late MockIGetBookDetails mockGetBookDetails; + late MockIStartReading mockStartReading; + late BookDetailsViewModel sut; + + setUp() { + mockSetFavorite = MockISetFavorite(); + mockGetFavorite = MockIGetFavorite(); + mockGetBookDetails = MockIGetBookDetails(); + mockStartReading = MockIStartReading(); + sut = BookDetailsViewModel( + mockGetBookDetails, + mockStartReading, + mockSetFavorite, + mockGetFavorite, + 'id', + ); + } + + group('test BookDetailsViewModel', () { + setUp(); + + test( + 'given bookId, when DidLoad called, should return favorite status', + () async { + when( + mockGetFavorite('id'), + ).thenAnswer((_) async => true); + when(mockGetBookDetails(id: 'id')).thenAnswer( + (_) async => fakeBookDomain, + ); + sut.add(DidLoadEvent('id')); + await expectLater( + sut.stream, + emits( + predicate( + (state) => state.isFavorite, + ), + ), + ); + verify(mockGetFavorite('id')).called(1); + }, + ); + + test( + 'given bookId, when DidClickFavoriteIcon called, should return favorite status', + () async { + when( + mockGetFavorite('id'), + ).thenAnswer((_) async => true); + when(mockGetBookDetails(id: 'id')).thenAnswer( + (_) async => fakeBookDomain, + ); + sut.add(DidClickFavoriteIconEvent()); + await expectLater( + sut.stream, + emits( + predicate( + (state) => !state.isFavorite, + ), + ), + ); + }, + ); + }); +}