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
1 change: 1 addition & 0 deletions lib/core/designsystem/atoms/colors.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
10 changes: 10 additions & 0 deletions lib/core/designsystem/organisms/app_nav_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -16,6 +17,7 @@ class AppNavBar extends StatelessWidget implements PreferredSizeWidget {
required this.titleText,
this.isTitleLoading = false,
this.textAlignment = AppNavBarTextAlignment.center,
this.trailing,
});

Widget _layout() {
Expand Down Expand Up @@ -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,
),
),
],
);

Expand Down
4 changes: 4 additions & 0 deletions lib/layers/data/api/custom_errors.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ class UnauthorizedError extends DioException {
class DuplicatedReadingError extends Error {
DuplicatedReadingError();
}

class DuplicatedBookError extends Error {
DuplicatedBookError();
}
115 changes: 106 additions & 9 deletions lib/layers/data/api/storage_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> saveReading(ReadingData readingData);
Future<List<ReadingData>> getReadingList();
Future<void> setFavoriteStatus(BookItem book, bool isFavorite);
Future<bool> getFavoriteStatus(String bookId);
Future<List<BookItem>> getFavoriteBooks();
}

@Singleton(as: IStorageClient)
@LazySingleton(as: IStorageClient)
class StorageClient implements IStorageClient {
Future<File> _getLocalFile(String fileName) async {
final directory = await getApplicationDocumentsDirectory();
return File('${directory.path}/$fileName.json');
}

Future<List<T>> _getListFromFile<T>(
String fileName,
T Function(Map<String, dynamic>) 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<dynamic> decoded = jsonDecode(jsonString);
return decoded.map((e) => fromJson(e)).toList();
}

Future<Map<String, bool>> _parseAsBoolMap(String jsonString) async {
final decoded = jsonDecode(jsonString);

if (decoded is! Map) {
throw Exception("JSON root is not a map");
}

return decoded.map<String, bool>((key, value) {
if (value is! bool) {
throw Exception("Value for '$key' is not a bool");
}
return MapEntry(key.toString(), value);
});
}

@override
Future<void> saveReading(ReadingData readingData) async {
final file = await _getLocalFile('reading_list');
final file = await _getLocalFile(_readingList);
List<ReadingData> currentList = await getReadingList();

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

@override
Future<List<ReadingData>> getReadingList() async {
final file = await _getLocalFile('reading_list');
final List<ReadingData> readings = await _getListFromFile<ReadingData>(
_readingList,
(json) => ReadingData.fromJson(json),
);
return readings;
}

if (!await file.exists()) {
return [];
@override
Future<List<BookItem>> getFavoriteBooks() async {
final List<BookItem> favoriteBooks = await _getListFromFile<BookItem>(
_favoriteBooks,
(json) => BookItem.fromJson(json),
);
return favoriteBooks;
}

@override
Future<void> setFavoriteStatus(BookItem book, bool isFavorite) async {
// 1. update favorite book list
final file = await _getLocalFile(_favoriteBooks);
List<BookItem> 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<String, bool> 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<bool> 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<dynamic> decoded = jsonDecode(jsonString);
return decoded.map((e) => ReadingData.fromJson(e)).toList();
Map<String, bool> isFavoriteBookMap = await _parseAsBoolMap(
jsonString,
);

return isFavoriteBookMap[bookId] ?? false;
}
}
31 changes: 31 additions & 0 deletions lib/layers/data/datasource/favorite_data_source.dart
Original file line number Diff line number Diff line change
@@ -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<void> setFavoriteStatus(BookItem book, bool isFavorite);
Future<bool> getFavoriteStatus(String bookId);
Future<List<BookItem>> getFavoriteBooks();
}

@LazySingleton(as: IFavoriteDataSource)
class FavoriteDataSource implements IFavoriteDataSource {
final IStorageClient storageClient;

FavoriteDataSource(this.storageClient);

@override
Future<void> setFavoriteStatus(BookItem book, bool isFavorite) async {
await storageClient.setFavoriteStatus(book, isFavorite);
}

@override
Future<bool> getFavoriteStatus(String bookId) async {
return await storageClient.getFavoriteStatus(bookId);
}

@override
Future<List<BookItem>> getFavoriteBooks() async {
return await storageClient.getFavoriteBooks();
}
}
24 changes: 19 additions & 5 deletions lib/layers/data/models/book_list_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
});

Expand All @@ -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()
Expand Down
26 changes: 26 additions & 0 deletions lib/layers/data/repository/favorite_repository.dart
Original file line number Diff line number Diff line change
@@ -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<List<BookDomain>> getFavoriteBooks() async =>
_dataSource.getFavoriteBooks().then(
(dataBooks) => dataBooks.map((e) => e.toDomain()).toList(),
);

@override
Future<bool> getFavoriteStatus(String bookId) async =>
_dataSource.getFavoriteStatus(bookId);

@override
Future<void> setFavoriteStatus(BookDomain book, bool isFavorite) async =>
_dataSource.setFavoriteStatus(BookItem.fromDomain(book), isFavorite);
}
2 changes: 1 addition & 1 deletion lib/layers/data/repository/reading_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
44 changes: 19 additions & 25 deletions lib/layers/domain/models/book_list_domain.dart
Original file line number Diff line number Diff line change
@@ -1,29 +1,23 @@
class BookListDomain {
final int totalItems;
final List<BookDomain> 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<BookDomain> books,
}) = _BookListDomain;
}

class BookDomain {
final String id;
final String kind;
final String title;
final List<String>? 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<String> authors,
String? description,
String? thumbnail,
@Default(0) int pageCount,
}) = _BookDomain;
}
7 changes: 7 additions & 0 deletions lib/layers/domain/repository/favorite_repository.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'package:mibook/layers/domain/models/book_list_domain.dart';

abstract class IFavoriteRepository {
Future<void> setFavoriteStatus(BookDomain book, bool isFavorite);
Future<bool> getFavoriteStatus(String bookId);
Future<List<BookDomain>> getFavoriteBooks();
}
17 changes: 17 additions & 0 deletions lib/layers/domain/usecases/get_favorite.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:injectable/injectable.dart';
import 'package:mibook/layers/domain/repository/favorite_repository.dart';

abstract class IGetFavorite {
Future<bool> call(String id);
}

@LazySingleton(as: IGetFavorite)
class GetFavorite implements IGetFavorite {
final IFavoriteRepository _favoriteRepository;

GetFavorite(this._favoriteRepository);

@override
Future<bool> call(String id) async =>
await _favoriteRepository.getFavoriteStatus(id);
}
Loading