Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/core/routes/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Expand Down
2 changes: 2 additions & 0 deletions lib/core/utils/strings.dart
Original file line number Diff line number Diff line change
@@ -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 = '%';
Expand Down
5 changes: 4 additions & 1 deletion lib/layers/data/api/storage_client.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -99,8 +100,10 @@ class StorageClient implements IStorageClient {
final file = await _getLocalFile(_favoriteBooks);
List<BookItem> 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
Expand Down
3 changes: 3 additions & 0 deletions lib/layers/data/models/book_list_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ class BookItem {
title: domain.title,
authors: domain.authors,
pageCount: domain.pageCount,
imageLinks: ImageLinks(
thumbnail: domain.thumbnail,
),
),
);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/layers/domain/models/feature.dart
Original file line number Diff line number Diff line change
@@ -1 +1 @@
enum Feature { readingList, search, objectives }
enum Feature { readingList, search, favorite, objectives }
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -26,6 +31,7 @@ class DashboardPage extends StatelessWidget {
featureList: [
Feature.readingList,
Feature.search,
Feature.favorite,
Feature.objectives,
],
onSelectIndex: router.setActiveIndex,
Expand Down
4 changes: 4 additions & 0 deletions lib/layers/presentation/navigation/dashboard/feature.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ extension FeatureUtils on Feature {
return strings.search;
case Feature.objectives:
return strings.objectives;
case Feature.favorite:
return strings.favorite;
}
}

Expand All @@ -22,6 +24,8 @@ extension FeatureUtils on Feature {
return Icons.search_outlined;
case Feature.objectives:
return Icons.book;
case Feature.favorite:
return Icons.favorite;
}
}
}
12 changes: 12 additions & 0 deletions lib/layers/presentation/navigation/tabs/favorite_tab.dart
Original file line number Diff line number Diff line change
@@ -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();
}
}
10 changes: 10 additions & 0 deletions lib/layers/presentation/screens/favoritelist/favorite_item_ui.dart
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
class FavoriteListEvent {}

class DidAppearEvent extends FavoriteListEvent {}

class DidTapUnfavoriteEvent extends FavoriteListEvent {
final String bookId;

DidTapUnfavoriteEvent(this.bookId);
}

class DidRefreshEvent extends FavoriteListEvent {}
Original file line number Diff line number Diff line change
@@ -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<FavoriteListViewModel, FavoriteListState>;

@RoutePage()
class FavoriteListPage extends StatelessWidget {
const FavoriteListPage({super.key});

@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: getIt<FavoriteListViewModel>(),
child: const FavoriteListScaffold(),
);
}
}

class FavoriteListScaffold extends StatelessWidget {
const FavoriteListScaffold({super.key});

@override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final viewModel = context.read<FavoriteListViewModel>();
viewModel.add(
DidAppearEvent(),
);
});
return Scaffold(
appBar: AppNavBar(
titleText: 'Favorite Books',
textAlignment: AppNavBarTextAlignment.center,
),
body: _BlocBuilder(
builder: (context, state) {
final viewModel = context.read<FavoriteListViewModel>();
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,
),
),
);
},
),
),
);
},
),
);
}
}
Original file line number Diff line number Diff line change
@@ -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<void, FavoriteListState> {
FavoriteListViewModel(super.initialState);
@injectable
class FavoriteListViewModel extends Bloc<FavoriteListEvent, FavoriteListState> {
final IGetFavoriteList _getFavoriteList;
final ISetFavorite _setFavorite;

FavoriteListViewModel(
this._getFavoriteList,
this._setFavorite,
) : super(FavoriteListState.initial()) {
on<DidAppearEvent>((event, emit) async {
final result = await _loadFavoriteBooks();
debugPrint('result = ${result.books.map((e) => e.thumbnail).toList()}');
emit(result);
});
on<DidTapUnfavoriteEvent>((event, emit) async {
final result = await _unfavoriteBook(event.bookId);
emit(result);
});
on<DidRefreshEvent>((event, emit) async {
final result = await _loadFavoriteBooks();
debugPrint('result = ${result.books.map((e) => e.thumbnail).toList()}');
emit(result);
});
}

Future<FavoriteListState> _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<FavoriteListState> _unfavoriteBook(String bookId) async {
final book = state.books.firstWhere((book) => book.id == bookId);
await _setFavorite(book.toDomain, false);
return _loadFavoriteBooks();
}
}
10 changes: 10 additions & 0 deletions test/layers/domain/fakes/fake_favorite_ui.dart
Original file line number Diff line number Diff line change
@@ -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',
);
Original file line number Diff line number Diff line change
@@ -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<IGetFavoriteList>(),
MockSpec<ISetFavorite>(),
])
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<FavoriteListState>(
(state) =>
state.books.length == 1 &&
state.books.first.id == fakeFavoriteUI.id,
),
),
);
verify(mockGetFavoriteList()).called(1);
});
});
}