Skip to content

Commit 229c7c5

Browse files
authored
Merge pull request #22 from CodandoTV/issue-20
[Issue 20]: Tag a book as favorite
2 parents 431b415 + 688d315 commit 229c7c5

36 files changed

Lines changed: 687 additions & 58 deletions

lib/core/designsystem/atoms/colors.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ const onBackground = Color(0xFFC6E0FA);
77
const background = Color(0xFF3D67FF);
88
const error = Color.fromARGB(255, 196, 8, 8);
99
const primaryText = Color(0xFF110000);
10+
const favorite = Color(0xFFFFBF00);

lib/core/designsystem/organisms/app_nav_bar.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ enum AppNavBarTextAlignment { center, start }
77
class AppNavBar extends StatelessWidget implements PreferredSizeWidget {
88
final String titleText;
99
final bool isTitleLoading;
10+
final Widget? trailing;
1011
final AppNavBarTextAlignment textAlignment;
1112
final Function()? onBack;
1213

@@ -16,6 +17,7 @@ class AppNavBar extends StatelessWidget implements PreferredSizeWidget {
1617
required this.titleText,
1718
this.isTitleLoading = false,
1819
this.textAlignment = AppNavBarTextAlignment.center,
20+
this.trailing,
1921
});
2022

2123
Widget _layout() {
@@ -45,6 +47,14 @@ class AppNavBar extends StatelessWidget implements PreferredSizeWidget {
4547
alignment: Alignment.center,
4648
child: _title(),
4749
),
50+
if (trailing != null)
51+
Transform.translate(
52+
offset: const Offset(0, -8),
53+
child: Align(
54+
alignment: Alignment.topRight,
55+
child: trailing,
56+
),
57+
),
4858
],
4959
);
5060

lib/layers/data/api/custom_errors.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ class UnauthorizedError extends DioException {
77
class DuplicatedReadingError extends Error {
88
DuplicatedReadingError();
99
}
10+
11+
class DuplicatedBookError extends Error {
12+
DuplicatedBookError();
13+
}

lib/layers/data/api/storage_client.dart

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,68 @@ import 'dart:io';
33

44
import 'package:injectable/injectable.dart';
55
import 'package:mibook/layers/data/api/custom_errors.dart';
6+
import 'package:mibook/layers/data/models/book_list_data.dart';
67
import 'package:mibook/layers/data/models/reading_data.dart';
78
import 'package:path_provider/path_provider.dart';
89

10+
const _readingList = 'reading_list';
11+
const _favoriteBooks = 'favorite_books';
12+
const _isFavoriteBook = 'is_favorite_book';
13+
914
abstract class IStorageClient {
1015
Future<void> saveReading(ReadingData readingData);
1116
Future<List<ReadingData>> getReadingList();
17+
Future<void> setFavoriteStatus(BookItem book, bool isFavorite);
18+
Future<bool> getFavoriteStatus(String bookId);
19+
Future<List<BookItem>> getFavoriteBooks();
1220
}
1321

14-
@Singleton(as: IStorageClient)
22+
@LazySingleton(as: IStorageClient)
1523
class StorageClient implements IStorageClient {
1624
Future<File> _getLocalFile(String fileName) async {
1725
final directory = await getApplicationDocumentsDirectory();
1826
return File('${directory.path}/$fileName.json');
1927
}
2028

29+
Future<List<T>> _getListFromFile<T>(
30+
String fileName,
31+
T Function(Map<String, dynamic>) fromJson,
32+
) async {
33+
final file = await _getLocalFile(fileName);
34+
final invalidFile = await file.exists() == false;
35+
36+
if (invalidFile) {
37+
return [];
38+
}
39+
40+
final jsonString = await file.readAsString();
41+
42+
if (jsonString.isEmpty) {
43+
return [];
44+
}
45+
46+
final List<dynamic> decoded = jsonDecode(jsonString);
47+
return decoded.map((e) => fromJson(e)).toList();
48+
}
49+
50+
Future<Map<String, bool>> _parseAsBoolMap(String jsonString) async {
51+
final decoded = jsonDecode(jsonString);
52+
53+
if (decoded is! Map) {
54+
throw Exception("JSON root is not a map");
55+
}
56+
57+
return decoded.map<String, bool>((key, value) {
58+
if (value is! bool) {
59+
throw Exception("Value for '$key' is not a bool");
60+
}
61+
return MapEntry(key.toString(), value);
62+
});
63+
}
64+
2165
@override
2266
Future<void> saveReading(ReadingData readingData) async {
23-
final file = await _getLocalFile('reading_list');
67+
final file = await _getLocalFile(_readingList);
2468
List<ReadingData> currentList = await getReadingList();
2569

2670
if (currentList.any((item) => item.bookId == readingData.bookId)) {
@@ -33,19 +77,72 @@ class StorageClient implements IStorageClient {
3377

3478
@override
3579
Future<List<ReadingData>> getReadingList() async {
36-
final file = await _getLocalFile('reading_list');
80+
final List<ReadingData> readings = await _getListFromFile<ReadingData>(
81+
_readingList,
82+
(json) => ReadingData.fromJson(json),
83+
);
84+
return readings;
85+
}
3786

38-
if (!await file.exists()) {
39-
return [];
87+
@override
88+
Future<List<BookItem>> getFavoriteBooks() async {
89+
final List<BookItem> favoriteBooks = await _getListFromFile<BookItem>(
90+
_favoriteBooks,
91+
(json) => BookItem.fromJson(json),
92+
);
93+
return favoriteBooks;
94+
}
95+
96+
@override
97+
Future<void> setFavoriteStatus(BookItem book, bool isFavorite) async {
98+
// 1. update favorite book list
99+
final file = await _getLocalFile(_favoriteBooks);
100+
List<BookItem> currentList = await getFavoriteBooks();
101+
102+
currentList.add(book);
103+
final jsonString = jsonEncode(currentList.map((e) => e.toJson()).toList());
104+
await file.writeAsString(jsonString);
105+
106+
// 2. update favorite status map
107+
final isFavoriteFile = await _getLocalFile(_isFavoriteBook);
108+
109+
Map<String, bool> isFavoriteBookMap = {};
110+
111+
// Safely read existing map
112+
if (await isFavoriteFile.exists()) {
113+
final jsonStringIsFavorite = await isFavoriteFile.readAsString();
114+
115+
if (jsonStringIsFavorite.isNotEmpty) {
116+
isFavoriteBookMap = await _parseAsBoolMap(jsonStringIsFavorite);
117+
}
40118
}
41119

42-
final jsonString = await file.readAsString();
120+
// Update or insert
121+
isFavoriteBookMap[book.id] = isFavorite;
122+
123+
// Write back
124+
final isFavoriteJsonString = jsonEncode(isFavoriteBookMap);
125+
await isFavoriteFile.writeAsString(isFavoriteJsonString);
126+
}
127+
128+
@override
129+
Future<bool> getFavoriteStatus(String bookId) async {
130+
final isFavoriteFile = await _getLocalFile(_isFavoriteBook);
131+
132+
if (!await isFavoriteFile.exists()) {
133+
return false;
134+
}
135+
136+
final jsonString = await isFavoriteFile.readAsString();
43137

44138
if (jsonString.isEmpty) {
45-
return [];
139+
return false;
46140
}
47141

48-
final List<dynamic> decoded = jsonDecode(jsonString);
49-
return decoded.map((e) => ReadingData.fromJson(e)).toList();
142+
Map<String, bool> isFavoriteBookMap = await _parseAsBoolMap(
143+
jsonString,
144+
);
145+
146+
return isFavoriteBookMap[bookId] ?? false;
50147
}
51148
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import 'package:injectable/injectable.dart';
2+
import 'package:mibook/layers/data/api/storage_client.dart';
3+
import 'package:mibook/layers/data/models/book_list_data.dart';
4+
5+
abstract class IFavoriteDataSource {
6+
Future<void> setFavoriteStatus(BookItem book, bool isFavorite);
7+
Future<bool> getFavoriteStatus(String bookId);
8+
Future<List<BookItem>> getFavoriteBooks();
9+
}
10+
11+
@LazySingleton(as: IFavoriteDataSource)
12+
class FavoriteDataSource implements IFavoriteDataSource {
13+
final IStorageClient storageClient;
14+
15+
FavoriteDataSource(this.storageClient);
16+
17+
@override
18+
Future<void> setFavoriteStatus(BookItem book, bool isFavorite) async {
19+
await storageClient.setFavoriteStatus(book, isFavorite);
20+
}
21+
22+
@override
23+
Future<bool> getFavoriteStatus(String bookId) async {
24+
return await storageClient.getFavoriteStatus(bookId);
25+
}
26+
27+
@override
28+
Future<List<BookItem>> getFavoriteBooks() async {
29+
return await storageClient.getFavoriteBooks();
30+
}
31+
}

lib/layers/data/models/book_list_data.dart

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ class BookItem {
3939
final String etag;
4040
final String selfLink;
4141
final VolumeInfo volumeInfo;
42-
final SaleInfo saleInfo;
43-
final AccessInfo accessInfo;
42+
final SaleInfo? saleInfo;
43+
final AccessInfo? accessInfo;
4444
final SearchInfo? searchInfo;
4545

4646
BookItem({
@@ -49,8 +49,8 @@ class BookItem {
4949
required this.etag,
5050
required this.selfLink,
5151
required this.volumeInfo,
52-
required this.saleInfo,
53-
required this.accessInfo,
52+
this.saleInfo,
53+
this.accessInfo,
5454
this.searchInfo,
5555
});
5656

@@ -64,12 +64,26 @@ class BookItem {
6464
id: id,
6565
kind: kind,
6666
title: volumeInfo.title,
67-
authors: volumeInfo.authors,
67+
authors: volumeInfo.authors ?? [],
6868
description: volumeInfo.description,
6969
thumbnail: volumeInfo.imageLinks?.thumbnail,
7070
pageCount: volumeInfo.pageCount ?? 100,
7171
);
7272
}
73+
74+
factory BookItem.fromDomain(BookDomain domain) {
75+
return BookItem(
76+
kind: domain.kind,
77+
id: domain.id,
78+
etag: '',
79+
selfLink: '',
80+
volumeInfo: VolumeInfo(
81+
title: domain.title,
82+
authors: domain.authors,
83+
pageCount: domain.pageCount,
84+
),
85+
);
86+
}
7387
}
7488

7589
@JsonSerializable()
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import 'package:injectable/injectable.dart';
2+
import 'package:mibook/layers/data/datasource/favorite_data_source.dart';
3+
import 'package:mibook/layers/data/models/book_list_data.dart';
4+
import 'package:mibook/layers/domain/models/book_list_domain.dart';
5+
import 'package:mibook/layers/domain/repository/favorite_repository.dart';
6+
7+
@LazySingleton(as: IFavoriteRepository)
8+
class FavoriteRepository implements IFavoriteRepository {
9+
final IFavoriteDataSource _dataSource;
10+
11+
FavoriteRepository(this._dataSource);
12+
13+
@override
14+
Future<List<BookDomain>> getFavoriteBooks() async =>
15+
_dataSource.getFavoriteBooks().then(
16+
(dataBooks) => dataBooks.map((e) => e.toDomain()).toList(),
17+
);
18+
19+
@override
20+
Future<bool> getFavoriteStatus(String bookId) async =>
21+
_dataSource.getFavoriteStatus(bookId);
22+
23+
@override
24+
Future<void> setFavoriteStatus(BookDomain book, bool isFavorite) async =>
25+
_dataSource.setFavoriteStatus(BookItem.fromDomain(book), isFavorite);
26+
}

lib/layers/data/repository/reading_repository.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import 'package:mibook/layers/data/models/reading_data.dart';
44
import 'package:mibook/layers/domain/models/reading_domain.dart';
55
import 'package:mibook/layers/domain/repository/reading_repository.dart';
66

7-
@Singleton(as: IReadingRepository)
7+
@LazySingleton(as: IReadingRepository)
88
class ReadingRepository implements IReadingRepository {
99
final IReadingDataSource _dataSource;
1010

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,23 @@
1-
class BookListDomain {
2-
final int totalItems;
3-
final List<BookDomain> books;
1+
import 'package:freezed_annotation/freezed_annotation.dart';
2+
part 'book_list_domain.freezed.dart';
43

5-
BookListDomain({
6-
required this.totalItems,
7-
required this.books,
8-
});
4+
@freezed
5+
class BookListDomain with _$BookListDomain {
6+
const factory BookListDomain({
7+
required int totalItems,
8+
required List<BookDomain> books,
9+
}) = _BookListDomain;
910
}
1011

11-
class BookDomain {
12-
final String id;
13-
final String kind;
14-
final String title;
15-
final List<String>? authors;
16-
final String? description;
17-
final String? thumbnail;
18-
final int pageCount;
19-
20-
BookDomain({
21-
required this.id,
22-
required this.kind,
23-
required this.title,
24-
required this.authors,
25-
required this.description,
26-
required this.thumbnail,
27-
required this.pageCount,
28-
});
12+
@freezed
13+
class BookDomain with _$BookDomain {
14+
const factory BookDomain({
15+
required String id,
16+
required String kind,
17+
required String title,
18+
@Default([]) List<String> authors,
19+
String? description,
20+
String? thumbnail,
21+
@Default(0) int pageCount,
22+
}) = _BookDomain;
2923
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import 'package:mibook/layers/domain/models/book_list_domain.dart';
2+
3+
abstract class IFavoriteRepository {
4+
Future<void> setFavoriteStatus(BookDomain book, bool isFavorite);
5+
Future<bool> getFavoriteStatus(String bookId);
6+
Future<List<BookDomain>> getFavoriteBooks();
7+
}

0 commit comments

Comments
 (0)