diff --git a/API_ENDPOINTS.md b/API_ENDPOINTS.md
new file mode 100644
index 0000000..0c5c062
--- /dev/null
+++ b/API_ENDPOINTS.md
@@ -0,0 +1,832 @@
+# MiBook API Endpoints Documentation
+
+This document provides a complete specification for all API endpoints needed to replace the local JSON storage with a real database backend.
+
+## Base Configuration
+
+```
+Base URL: https://api.mibook.com/v1
+Content-Type: application/json
+```
+
+---
+
+## Authentication Endpoints
+
+### 1. Sign Up (Register)
+
+**Endpoint:** `POST /auth/signup`
+
+**Headers:**
+```json
+{
+ "Content-Type": "application/json"
+}
+```
+
+**Request Body:**
+```json
+{
+ "email": "user@example.com",
+ "password": "securePassword123",
+ "displayName": "John Doe"
+}
+```
+
+**Response (201 Created):**
+```json
+{
+ "id": "user123",
+ "email": "user@example.com",
+ "displayName": "John Doe",
+ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "expiresIn": 3600
+}
+```
+
+**Error Response (400 Bad Request):**
+```json
+{
+ "error": "EMAIL_ALREADY_EXISTS",
+ "message": "An account with this email already exists"
+}
+```
+
+---
+
+### 2. Sign In (Login)
+
+**Endpoint:** `POST /auth/signin`
+
+**Headers:**
+```json
+{
+ "Content-Type": "application/json"
+}
+```
+
+**Request Body:**
+```json
+{
+ "email": "user@example.com",
+ "password": "securePassword123"
+}
+```
+
+**Response (200 OK):**
+```json
+{
+ "id": "user123",
+ "email": "user@example.com",
+ "displayName": "John Doe",
+ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "expiresIn": 3600
+}
+```
+
+**Error Response (401 Unauthorized):**
+```json
+{
+ "error": "INVALID_CREDENTIALS",
+ "message": "Email or password is incorrect"
+}
+```
+
+---
+
+### 3. Sign Out (Logout)
+
+**Endpoint:** `POST /auth/signout`
+
+**Headers:**
+```json
+{
+ "Content-Type": "application/json",
+ "Authorization": "Bearer {accessToken}"
+}
+```
+
+**Request Body:**
+```json
+{
+ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+}
+```
+
+**Response (200 OK):**
+```json
+{
+ "message": "Successfully signed out"
+}
+```
+
+---
+
+### 4. Refresh Token
+
+**Endpoint:** `POST /auth/refresh`
+
+**Headers:**
+```json
+{
+ "Content-Type": "application/json"
+}
+```
+
+**Request Body:**
+```json
+{
+ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+}
+```
+
+**Response (200 OK):**
+```json
+{
+ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "expiresIn": 3600
+}
+```
+
+---
+
+## Reading List Endpoints
+
+### 5. Start Reading (Add to Reading List)
+
+**Endpoint:** `POST /readings`
+
+**HTTP Method:** POST
+
+**Headers:**
+```json
+{
+ "Content-Type": "application/json",
+ "Authorization": "Bearer {accessToken}"
+}
+```
+
+**Request Body:**
+```json
+{
+ "bookId": "book_id_123",
+ "bookName": "Harry Potter and the Sorcerer's Stone",
+ "bookThumb": "https://example.com/images/harry-potter.jpg",
+ "progress": 0.0
+}
+```
+
+**Response (201 Created):**
+```json
+{
+ "id": "reading_123",
+ "bookId": "book_id_123",
+ "bookName": "Harry Potter and the Sorcerer's Stone",
+ "bookThumb": "https://example.com/images/harry-potter.jpg",
+ "progress": 0.0,
+ "createdAt": "2026-02-04T10:30:00Z",
+ "updatedAt": "2026-02-04T10:30:00Z"
+}
+```
+
+**Error Response (409 Conflict - Book already in reading list):**
+```json
+{
+ "error": "DUPLICATED_READING",
+ "message": "This book is already in your reading list"
+}
+```
+
+---
+
+### 6. Get Reading List
+
+**Endpoint:** `GET /readings`
+
+**HTTP Method:** GET
+
+**Headers:**
+```json
+{
+ "Authorization": "Bearer {accessToken}"
+}
+```
+
+**Query Parameters:**
+- `skip` (optional): Number of records to skip (default: 0)
+- `limit` (optional): Number of records to return (default: 20)
+
+**Response (200 OK):**
+```json
+{
+ "items": [
+ {
+ "id": "reading_123",
+ "bookId": "book_id_123",
+ "bookName": "Harry Potter and the Sorcerer's Stone",
+ "bookThumb": "https://example.com/images/harry-potter.jpg",
+ "progress": 0.25,
+ "createdAt": "2026-02-04T10:30:00Z",
+ "updatedAt": "2026-02-05T15:45:00Z"
+ },
+ {
+ "id": "reading_124",
+ "bookId": "book_id_456",
+ "bookName": "Deltora Quest",
+ "bookThumb": "https://example.com/images/deltora.jpg",
+ "progress": 0.5,
+ "createdAt": "2026-02-03T08:20:00Z",
+ "updatedAt": "2026-02-05T15:45:00Z"
+ }
+ ],
+ "total": 2,
+ "skip": 0,
+ "limit": 20
+}
+```
+
+---
+
+### 7. Update Reading Progress
+
+**Endpoint:** `PATCH /readings/{readingId}`
+
+**HTTP Method:** PATCH
+
+**Headers:**
+```json
+{
+ "Content-Type": "application/json",
+ "Authorization": "Bearer {accessToken}"
+}
+```
+
+**Request Body:**
+```json
+{
+ "progress": 0.35
+}
+```
+
+**Response (200 OK):**
+```json
+{
+ "id": "reading_123",
+ "bookId": "book_id_123",
+ "bookName": "Harry Potter and the Sorcerer's Stone",
+ "bookThumb": "https://example.com/images/harry-potter.jpg",
+ "progress": 0.35,
+ "createdAt": "2026-02-04T10:30:00Z",
+ "updatedAt": "2026-02-05T16:00:00Z"
+}
+```
+
+---
+
+### 8. Remove from Reading List
+
+**Endpoint:** `DELETE /readings/{readingId}`
+
+**HTTP Method:** DELETE
+
+**Headers:**
+```json
+{
+ "Authorization": "Bearer {accessToken}"
+}
+```
+
+**Response (204 No Content)**
+
+---
+
+## Favorites Endpoints
+
+### 9. Set Favorite Status
+
+**Endpoint:** `POST /favorites`
+
+**HTTP Method:** POST
+
+**Headers:**
+```json
+{
+ "Content-Type": "application/json",
+ "Authorization": "Bearer {accessToken}"
+}
+```
+
+**Request Body:**
+```json
+{
+ "bookId": "book_id_123",
+ "isFavorite": true,
+ "bookData": {
+ "kind": "books#volume",
+ "id": "book_id_123",
+ "etag": "abcdef123456",
+ "selfLink": "https://www.googleapis.com/books/v1/volumes/book_id_123",
+ "volumeInfo": {
+ "title": "Harry Potter and the Sorcerer's Stone",
+ "authors": ["J.K. Rowling"],
+ "description": "A young wizard's first year at a magical school",
+ "imageLinks": {
+ "thumbnail": "https://example.com/images/harry-potter.jpg"
+ },
+ "pageCount": 309
+ }
+ }
+}
+```
+
+**Response (201 Created / 200 OK):**
+```json
+{
+ "id": "favorite_123",
+ "bookId": "book_id_123",
+ "isFavorite": true,
+ "createdAt": "2026-02-04T10:30:00Z",
+ "updatedAt": "2026-02-04T10:30:00Z"
+}
+```
+
+---
+
+### 10. Get Favorite Status
+
+**Endpoint:** `GET /favorites/{bookId}`
+
+**HTTP Method:** GET
+
+**Headers:**
+```json
+{
+ "Authorization": "Bearer {accessToken}"
+}
+```
+
+**Response (200 OK):**
+```json
+{
+ "bookId": "book_id_123",
+ "isFavorite": true
+}
+```
+
+**Response (404 Not Found):**
+```json
+{
+ "bookId": "book_id_123",
+ "isFavorite": false
+}
+```
+
+---
+
+### 11. Get All Favorite Books
+
+**Endpoint:** `GET /favorites`
+
+**HTTP Method:** GET
+
+**Headers:**
+```json
+{
+ "Authorization": "Bearer {accessToken}"
+}
+```
+
+**Query Parameters:**
+- `skip` (optional): Number of records to skip (default: 0)
+- `limit` (optional): Number of records to return (default: 20)
+
+**Response (200 OK):**
+```json
+{
+ "items": [
+ {
+ "id": "favorite_123",
+ "kind": "books#volume",
+ "bookId": "book_id_123",
+ "etag": "abcdef123456",
+ "selfLink": "https://www.googleapis.com/books/v1/volumes/book_id_123",
+ "volumeInfo": {
+ "title": "Harry Potter and the Sorcerer's Stone",
+ "authors": ["J.K. Rowling"],
+ "description": "A young wizard's first year at a magical school",
+ "imageLinks": {
+ "thumbnail": "https://example.com/images/harry-potter.jpg"
+ },
+ "pageCount": 309
+ },
+ "createdAt": "2026-02-04T10:30:00Z"
+ }
+ ],
+ "total": 1,
+ "skip": 0,
+ "limit": 20
+}
+```
+
+---
+
+### 12. Delete Favorite
+
+**Endpoint:** `DELETE /favorites/{bookId}`
+
+**HTTP Method:** DELETE
+
+**Headers:**
+```json
+{
+ "Authorization": "Bearer {accessToken}"
+}
+```
+
+**Response (204 No Content)**
+
+---
+
+## Search/Browse Books Endpoints
+
+### 13. Search Books by Title (With Caching)
+
+**Endpoint:** `GET /books/search`
+
+**HTTP Method:** GET
+
+**Headers:**
+```json
+{
+ "Content-Type": "application/json"
+}
+```
+
+**Query Parameters:**
+- `q` (required): Search query (e.g., "intitle:Harry Potter")
+- `startIndex` (required): Starting position of results
+- `maxResults` (optional): Maximum results to return (default: 40)
+
+**Description:**
+This endpoint searches for books using Google Books API with smart caching. The backend:
+1. Checks if cached results exist for the query
+2. If cache is fresh (updated within 24 hours), returns cached data
+3. If cache is stale or missing, attempts to fetch from Google Books API
+4. If Google Books returns 429 (rate limit), serves from cache regardless of age
+5. If no cache exists and Google Books fails, returns 503 with error message
+
+**Response (200 OK - Fresh Cache or Live Data):**
+```json
+{
+ "kind": "books#volumes",
+ "totalItems": 1500,
+ "items": [
+ {
+ "kind": "books#volume",
+ "id": "book_id_123",
+ "etag": "abcdef123456",
+ "selfLink": "https://www.googleapis.com/books/v1/volumes/book_id_123",
+ "volumeInfo": {
+ "title": "Harry Potter and the Sorcerer's Stone",
+ "authors": ["J.K. Rowling"],
+ "description": "A young wizard's first year at a magical school",
+ "imageLinks": {
+ "thumbnail": "https://example.com/images/harry-potter.jpg"
+ },
+ "pageCount": 309
+ }
+ }
+ ],
+ "fromCache": false,
+ "cacheExpiresAt": "2026-02-05T10:30:00Z",
+ "lastUpdated": "2026-02-04T10:30:00Z"
+}
+```
+
+**Response (200 OK - Fallback to Stale Cache):**
+```json
+{
+ "kind": "books#volumes",
+ "totalItems": 1500,
+ "items": [
+ {
+ "kind": "books#volume",
+ "id": "book_id_123",
+ "etag": "abcdef123456",
+ "selfLink": "https://www.googleapis.com/books/v1/volumes/book_id_123",
+ "volumeInfo": {
+ "title": "Harry Potter and the Sorcerer's Stone",
+ "authors": ["J.K. Rowling"],
+ "description": "A young wizard's first year at a magical school",
+ "imageLinks": {
+ "thumbnail": "https://example.com/images/harry-potter.jpg"
+ },
+ "pageCount": 309
+ }
+ }
+ ],
+ "fromCache": true,
+ "warning": "Google Books API rate limit reached. Serving cached data.",
+ "cacheExpiresAt": "2026-02-05T10:30:00Z",
+ "lastUpdated": "2026-02-01T10:30:00Z"
+}
+```
+
+**Error Response (503 Service Unavailable - No Cache Available):**
+```json
+{
+ "error": "GOOGLE_BOOKS_UNAVAILABLE",
+ "message": "Google Books API is temporarily unavailable and no cached data is available",
+ "timestamp": "2026-02-04T10:30:00Z"
+}
+```
+
+---
+
+### 14. Get Book Details by ID (With Caching)
+
+**Endpoint:** `GET /books/{bookId}`
+
+**HTTP Method:** GET
+
+**Headers:**
+```json
+{
+ "Content-Type": "application/json"
+}
+```
+
+**Description:**
+This endpoint retrieves detailed information about a specific book using Google Books API with smart caching. The backend:
+1. Checks if cached book details exist
+2. If cache is fresh (updated within 24 hours), returns cached data
+3. If cache is stale or missing, attempts to fetch from Google Books API
+4. If Google Books returns 429 (rate limit), serves from cache regardless of age
+5. If no cache exists and Google Books fails, returns 503 with error message
+
+**Response (200 OK - Fresh Cache or Live Data):**
+```json
+{
+ "kind": "books#volume",
+ "id": "book_id_123",
+ "etag": "abcdef123456",
+ "selfLink": "https://www.googleapis.com/books/v1/volumes/book_id_123",
+ "volumeInfo": {
+ "title": "Harry Potter and the Sorcerer's Stone",
+ "authors": ["J.K. Rowling"],
+ "description": "A young wizard's first year at a magical school",
+ "publishedDate": "1997-06-26",
+ "pageCount": 309,
+ "imageLinks": {
+ "smallThumbnail": "https://example.com/small.jpg",
+ "thumbnail": "https://example.com/images/harry-potter.jpg"
+ },
+ "language": "en",
+ "previewLink": "https://books.google.com/books?id=...",
+ "infoLink": "https://books.google.com/books?id=..."
+ },
+ "fromCache": false,
+ "cacheExpiresAt": "2026-02-05T10:30:00Z",
+ "lastUpdated": "2026-02-04T10:30:00Z"
+}
+```
+
+**Response (200 OK - Fallback to Stale Cache):**
+```json
+{
+ "kind": "books#volume",
+ "id": "book_id_123",
+ "etag": "abcdef123456",
+ "selfLink": "https://www.googleapis.com/books/v1/volumes/book_id_123",
+ "volumeInfo": {
+ "title": "Harry Potter and the Sorcerer's Stone",
+ "authors": ["J.K. Rowling"],
+ "description": "A young wizard's first year at a magical school",
+ "publishedDate": "1997-06-26",
+ "pageCount": 309,
+ "imageLinks": {
+ "smallThumbnail": "https://example.com/small.jpg",
+ "thumbnail": "https://example.com/images/harry-potter.jpg"
+ },
+ "language": "en",
+ "previewLink": "https://books.google.com/books?id=...",
+ "infoLink": "https://books.google.com/books?id=..."
+ },
+ "fromCache": true,
+ "warning": "Google Books API rate limit reached. Serving cached data.",
+ "cacheExpiresAt": "2026-02-05T10:30:00Z",
+ "lastUpdated": "2026-02-01T10:30:00Z"
+}
+```
+
+**Error Response (503 Service Unavailable - No Cache Available):**
+```json
+{
+ "error": "GOOGLE_BOOKS_UNAVAILABLE",
+ "message": "Google Books API is temporarily unavailable and no cached data is available for book ID: book_id_123",
+ "timestamp": "2026-02-04T10:30:00Z"
+}
+```
+
+**Error Response (404 Not Found):**
+```json
+{
+ "error": "BOOK_NOT_FOUND",
+ "message": "Book not found in cache or Google Books API",
+ "timestamp": "2026-02-04T10:30:00Z"
+}
+```
+
+---
+
+## Error Handling
+
+### Standard Error Response Format
+
+All error responses follow this format:
+
+```json
+{
+ "error": "ERROR_CODE",
+ "message": "Human-readable error message",
+ "timestamp": "2026-02-04T10:30:00Z"
+}
+```
+
+### Common HTTP Status Codes
+
+| Status Code | Meaning |
+|-------------|---------|
+| 200 | OK - Request successful |
+| 201 | Created - Resource created successfully |
+| 204 | No Content - Request successful, no response body |
+| 400 | Bad Request - Invalid request parameters |
+| 401 | Unauthorized - Missing or invalid authentication token |
+| 403 | Forbidden - Insufficient permissions |
+| 404 | Not Found - Resource not found |
+| 409 | Conflict - Resource already exists (e.g., duplicate reading) |
+| 500 | Internal Server Error - Server error |
+
+---
+
+## Authentication Flow
+
+### Token Management
+
+1. **Initial Login:** User calls Sign In endpoint, receives `accessToken` and `refreshToken`
+2. **API Requests:** Include `accessToken` in Authorization header
+3. **Token Expiration:** When token expires (401 response), call Refresh Token endpoint
+4. **Logout:** Call Sign Out endpoint to invalidate tokens
+
+### Authorization Header Format
+
+```
+Authorization: Bearer {accessToken}
+```
+
+---
+
+## Implementation Notes
+
+### For APIClient Class
+
+Extend the `IApiClient` interface to include:
+- Token management (storing and refreshing access tokens)
+- Authorization header injection for all authenticated requests
+- Error handling for 401 responses (auto-refresh token)
+- Updated base URL to point to your backend API only
+- Remove direct Google Books API calls - all book search/details go through your backend
+
+### For StorageClient Class
+
+Replace file-based operations with:
+- HTTP requests to backend endpoints
+- Stream management for real-time updates (consider WebSocket for watch operations)
+- Local caching strategy for offline support
+- Conflict resolution for concurrent updates
+
+### Backend Caching Strategy
+
+The backend implements a smart caching system for Google Books API:
+
+**Cache Lifecycle:**
+1. **Fresh Cache (< 24 hours):** Serve from cache immediately
+2. **Stale Cache (> 24 hours):** Attempt live fetch
+3. **Live Fetch Fails (429 Rate Limit):** Serve from cache regardless of age
+4. **No Cache + Live Fetch Fails:** Return 503 Service Unavailable
+
+**Cache Key Structure:**
+- **Search queries:** `books:search:{hash(q)}:{startIndex}:{maxResults}`
+- **Book details:** `books:detail:{bookId}`
+
+**Automatic Cache Refresh:**
+- Backend should implement scheduled tasks to refresh popular search queries
+- Cache TTL: 24 hours (86,400 seconds)
+- Store `lastUpdated` and `cacheExpiresAt` timestamps in responses
+
+**Benefits:**
+- ✅ Handles rate limiting gracefully
+- ✅ Faster response times for popular queries
+- ✅ Reduced API quota consumption
+- ✅ Better user experience with consistent data availability
+- ✅ Supports offline access patterns through your app's local storage
+
+### Data Models to Update
+
+Ensure the following Dart models are serializable and match backend response:
+- `ReadingData` - for reading list operations
+- `BookData` - for book information
+- `User` - for authentication responses
+- Add fields to responses: `fromCache`, `warning` (optional), `lastUpdated`, `cacheExpiresAt`
+
+---
+
+## Example Implementation References
+
+### Sign In Flow
+```dart
+// 1. Call sign in
+final response = await apiClient.post(
+ endpoint: 'auth/signin',
+ body: {'email': email, 'password': password},
+);
+
+// 2. Store tokens
+final tokens = AuthResponse.fromJson(response);
+secureStorage.saveAccessToken(tokens.accessToken);
+secureStorage.saveRefreshToken(tokens.refreshToken);
+
+// 3. Use accessToken for subsequent requests
+```
+
+### Search Books with Smart Caching
+```dart
+// 1. Call backend search endpoint (NOT Google Books directly)
+final response = await apiClient.get(
+ endpoint: 'books/search',
+ queryParameters: {
+ 'q': 'intitle:Harry Potter',
+ 'startIndex': 0,
+ 'maxResults': 40,
+ },
+);
+
+// 2. Parse response
+final bookList = BookListData.fromJson(response);
+
+// 3. Check cache status in response
+if (response['fromCache'] == true) {
+ print('Serving from cache (last updated: ${response['lastUpdated']})');
+ if (response.containsKey('warning')) {
+ print('Warning: ${response['warning']}');
+ }
+} else {
+ print('Serving fresh data from Google Books API');
+}
+```
+
+### Get Book Details with Smart Caching
+```dart
+// 1. Call backend details endpoint (NOT Google Books directly)
+final response = await apiClient.get(
+ endpoint: 'books/book_id_123',
+);
+
+// 2. Parse response
+final bookData = BookData.fromJson(response);
+
+// 3. Handle potential service unavailability
+if (response.statusCode == 503) {
+ print('Google Books API unavailable and no cache exists');
+ showErrorToUser('Book details temporarily unavailable. Please try again later.');
+}
+```
+
+### Add to Reading List
+```dart
+// 1. Create reading object
+final reading = ReadingData(
+ bookId: book.id,
+ bookName: book.title,
+ bookThumb: book.thumbnail,
+ progress: 0.0,
+);
+
+// 2. POST to /readings
+final response = await apiClient.post(
+ endpoint: 'readings',
+ body: reading.toJson(),
+);
+
+// 3. Notify listeners of change
+_readingListController.add(await getReadingList());
+```
+
+### Watch Readings (Real-time Updates)
+For the `watchReadingList()` stream, consider:
+- **Option 1:** Poll the endpoint periodically (simple, less efficient)
+- **Option 2:** WebSocket connection for push updates (efficient, real-time)
+- **Option 3:** Local stream with background sync (offline support)
diff --git a/images/empty-list.png b/images/empty-list.png
new file mode 100644
index 0000000..872663d
Binary files /dev/null and b/images/empty-list.png differ
diff --git a/images/icons/mibook_icon.png b/images/icons/mibook_icon.png
new file mode 100644
index 0000000..4e5712d
Binary files /dev/null and b/images/icons/mibook_icon.png differ
diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist
index 1dc6cf7..391a902 100644
--- a/ios/Flutter/AppFrameworkInfo.plist
+++ b/ios/Flutter/AppFrameworkInfo.plist
@@ -20,7 +20,5 @@
????
CFBundleVersion
1.0
- MinimumOSVersion
- 13.0
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 8f07c66..f9d59a0 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -540,7 +540,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
- ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -597,7 +597,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
- ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 6266644..c30b367 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -2,12 +2,15 @@ import Flutter
import UIKit
@main
-@objc class AppDelegate: FlutterAppDelegate {
+@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
- GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+
+ func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+ GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+ }
}
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
index dc9ada4..37aeedd 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
index 7353c41..f5b6814 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
index 797d452..65544e5 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
index 6ed2d93..244dc90 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
index 4cd7b00..128a4b1 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
index fe73094..eb7b3e5 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
index 321773c..4ce081b 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
index 797d452..65544e5 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
index 502f463..7241f52 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
index 0ec3034..226a49b 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
new file mode 100644
index 0000000..33c53b7
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
new file mode 100644
index 0000000..cce747d
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
new file mode 100644
index 0000000..7496db7
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
new file mode 100644
index 0000000..971a4ed
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
index 0ec3034..226a49b 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
index e9f5fea..abb2948 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
new file mode 100644
index 0000000..71a63e6
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
new file mode 100644
index 0000000..319b5c3
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
index 84ac32a..d946a0f 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
index 8953cba..f6677d8 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
index 0467bf1..17acfe9 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index d421510..35c819d 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -2,6 +2,8 @@
+ CADisableMinimumFrameDurationOnPhone
+
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
@@ -24,6 +26,29 @@
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ FlutterSceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
@@ -41,9 +66,5 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
- CADisableMinimumFrameDurationOnPhone
-
- UIApplicationSupportsIndirectInputEvents
-
diff --git a/lib/core/designsystem/atoms/dimensions.dart b/lib/core/designsystem/atoms/dimensions.dart
new file mode 100644
index 0000000..291b3bd
--- /dev/null
+++ b/lib/core/designsystem/atoms/dimensions.dart
@@ -0,0 +1,10 @@
+class Spacings {
+ static const double xSmall = 4.0;
+ static const double small = 8.0;
+ static const double medium = 16.0;
+ static const double large = 24.0;
+ static const double xLarge = 32.0;
+ static const double xl2 = 40.0;
+ static const double xl3 = 56.0;
+ static const double xl4 = 64.0;
+}
diff --git a/lib/core/designsystem/atoms/typography.dart b/lib/core/designsystem/atoms/typography.dart
new file mode 100644
index 0000000..a5a5fdb
--- /dev/null
+++ b/lib/core/designsystem/atoms/typography.dart
@@ -0,0 +1,53 @@
+import 'package:flutter/widgets.dart';
+
+const double labelXS = 12;
+const double labelS = 14;
+const double labelM = 16;
+const double labelL = 18;
+
+const double defaultTiny = 10;
+const double defaultSBody = 12;
+const double defaultBody = 14;
+const double defaultXS2 = 18;
+const double defaultXS = 20;
+const double defaultS = 24;
+const double defaultM = 28;
+const double defaultL = 32;
+const double defaultXL = 40;
+const double defaultXL2 = 48;
+
+final headingH1 = TextStyle(
+ fontFamily: 'Poppins',
+ fontSize: defaultXL2,
+ fontWeight: FontWeight.w700,
+);
+
+final headingH2 = TextStyle(
+ fontFamily: 'Poppins',
+ fontSize: defaultXL,
+ fontWeight: FontWeight.w700,
+);
+
+final headingH3 = TextStyle(
+ fontFamily: 'Poppins',
+ fontSize: defaultL,
+ fontWeight: FontWeight.w700,
+);
+
+final headingH4 = TextStyle(
+ fontFamily: 'Poppins',
+ fontSize: defaultM,
+ fontWeight: FontWeight.w700,
+);
+
+final headingH5 = TextStyle(
+ fontFamily: 'Poppins',
+ fontSize: defaultS,
+ fontWeight: FontWeight.w700,
+);
+
+final headingH6 = TextStyle(
+ fontFamily: 'Poppins',
+ fontSize: defaultXS,
+ fontWeight: FontWeight.w700,
+);
diff --git a/lib/core/designsystem/organisms/list_item.dart b/lib/core/designsystem/organisms/list_item.dart
index 82a0e6d..9c4e24b 100644
--- a/lib/core/designsystem/organisms/list_item.dart
+++ b/lib/core/designsystem/organisms/list_item.dart
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
+import 'package:flutter_html/flutter_html.dart';
import 'package:mibook/core/designsystem/atoms/colors.dart';
+import 'package:mibook/core/designsystem/molecules/indicators/progress_stepper.dart';
abstract class ListItemInput {
Widget get content;
}
-class BookItemInput extends ListItemInput {
+class TitleImageDescriptionInput extends ListItemInput {
final String id;
final String kind;
final String title;
@@ -13,7 +15,7 @@ class BookItemInput extends ListItemInput {
final String description;
final String? thumbnail;
- BookItemInput({
+ TitleImageDescriptionInput({
required this.id,
required this.kind,
required this.title,
@@ -71,6 +73,97 @@ class GenericInput extends ListItemInput {
Widget get content => child;
}
+class TitleImageProgressInput extends ListItemInput {
+ final String title;
+ final String? thumbnail;
+ final double progress; // value between 0.0 and 1.0
+
+ TitleImageProgressInput({
+ required this.title,
+ required this.progress,
+ this.thumbnail,
+ });
+
+ @override
+ Widget get content {
+ return Row(
+ children: [
+ if (thumbnail != null)
+ Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: SizedBox(
+ width: 32,
+ height: 32,
+ child: Image.network(
+ thumbnail!,
+ width: 50,
+ height: 75,
+ fit: BoxFit.cover,
+ ),
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: const TextStyle(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 8),
+ ProgressStepper(progress: progress),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class TitleSubtitleHtmlDescriptionInput extends ListItemInput {
+ final String title;
+ final String subtitle;
+ final String description;
+
+ TitleSubtitleHtmlDescriptionInput({
+ required this.title,
+ required this.subtitle,
+ required this.description,
+ });
+
+ @override
+ Widget get content {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 8),
+ if (subtitle.isNotEmpty)
+ Text(
+ subtitle,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ const SizedBox(height: 16),
+ Html(data: description),
+ ],
+ );
+ }
+}
+
class ListItem extends StatelessWidget {
final ListItemInput input;
final bool isExpanded;
@@ -94,7 +187,7 @@ class ListItem extends StatelessWidget {
color: onBackground,
),
child: Padding(
- padding: const EdgeInsets.all(8.0),
+ padding: const EdgeInsets.all(16.0),
child: input.content,
),
),
diff --git a/lib/layers/data/api/storage_client.dart b/lib/layers/data/api/storage_client.dart
index 087b8ef..5e836e6 100644
--- a/lib/layers/data/api/storage_client.dart
+++ b/lib/layers/data/api/storage_client.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -15,13 +16,27 @@ const _isFavoriteBook = 'is_favorite_book';
abstract class IStorageClient {
Future saveReading(ReadingData readingData);
Future> getReadingList();
- Future setFavoriteStatus(BookItem book, bool isFavorite);
+ Future setFavoriteStatus(BookData book, bool isFavorite);
Future getFavoriteStatus(String bookId);
- Future> getFavoriteBooks();
+ Future> getFavoriteBooks();
+ Stream> watchReadingList();
+ Stream> watchFavoriteBooks();
+ void startReadingListWatcher();
+ void startFavoriteBooksWatcher();
+ void dispose();
}
@LazySingleton(as: IStorageClient)
class StorageClient implements IStorageClient {
+ final StreamController> _readingListController =
+ StreamController>.broadcast();
+ final StreamController> _favoriteBooksController =
+ StreamController>.broadcast();
+
+ StorageClient() {
+ // Initialize the reading list stream with current data
+ }
+
Future _getLocalFile(String fileName) async {
final directory = await getApplicationDocumentsDirectory();
return File('${directory.path}/$fileName.json');
@@ -65,6 +80,7 @@ class StorageClient implements IStorageClient {
@override
Future saveReading(ReadingData readingData) async {
+ // Save new reading data to local storage
final file = await _getLocalFile(_readingList);
List currentList = await getReadingList();
@@ -74,6 +90,11 @@ class StorageClient implements IStorageClient {
currentList.add(readingData);
final jsonString = jsonEncode(currentList.map((e) => e.toJson()).toList());
await file.writeAsString(jsonString);
+
+ // Fetch updated list and add to stream
+ final updatedList = await getReadingList();
+ // This will trigger a refresh in any listeners
+ _readingListController.add(updatedList);
}
@override
@@ -86,19 +107,19 @@ class StorageClient implements IStorageClient {
}
@override
- Future> getFavoriteBooks() async {
- final List favoriteBooks = await _getListFromFile(
+ Future> getFavoriteBooks() async {
+ final List favoriteBooks = await _getListFromFile(
_favoriteBooks,
- (json) => BookItem.fromJson(json),
+ (json) => BookData.fromJson(json),
);
return favoriteBooks;
}
@override
- Future setFavoriteStatus(BookItem book, bool isFavorite) async {
+ Future setFavoriteStatus(BookData book, bool isFavorite) async {
// 1. update favorite book list
final file = await _getLocalFile(_favoriteBooks);
- List currentList = await getFavoriteBooks();
+ List currentList = await getFavoriteBooks();
currentList.removeWhere((e) => e.id == book.id);
if (isFavorite) currentList.add(book);
@@ -126,6 +147,7 @@ class StorageClient implements IStorageClient {
// Write back
final isFavoriteJsonString = jsonEncode(isFavoriteBookMap);
await isFavoriteFile.writeAsString(isFavoriteJsonString);
+ _favoriteBooksController.add(currentList);
}
@override
@@ -148,4 +170,33 @@ class StorageClient implements IStorageClient {
return isFavoriteBookMap[bookId] ?? false;
}
+
+ @override
+ Stream> watchReadingList() => _readingListController.stream;
+
+ @override
+ Stream> watchFavoriteBooks() =>
+ _favoriteBooksController.stream;
+
+ @override
+ void startReadingListWatcher() {
+ Timer.periodic(const Duration(seconds: 2), (timer) async {
+ final currentList = await getReadingList();
+ _readingListController.add(currentList);
+ });
+ }
+
+ @override
+ void startFavoriteBooksWatcher() {
+ Timer.periodic(const Duration(seconds: 2), (timer) async {
+ final currentList = await getFavoriteBooks();
+ _favoriteBooksController.add(currentList);
+ });
+ }
+
+ @override
+ void dispose() {
+ _readingListController.close();
+ _favoriteBooksController.close();
+ }
}
diff --git a/lib/layers/data/datasource/favorite_data_source.dart b/lib/layers/data/datasource/favorite_data_source.dart
index b170a1c..3abb2af 100644
--- a/lib/layers/data/datasource/favorite_data_source.dart
+++ b/lib/layers/data/datasource/favorite_data_source.dart
@@ -3,9 +3,10 @@ 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 setFavoriteStatus(BookData book, bool isFavorite);
Future getFavoriteStatus(String bookId);
- Future> getFavoriteBooks();
+ Future> getFavoriteBooks();
+ Stream> watchFavoriteBooks();
}
@LazySingleton(as: IFavoriteDataSource)
@@ -15,7 +16,7 @@ class FavoriteDataSource implements IFavoriteDataSource {
FavoriteDataSource(this.storageClient);
@override
- Future setFavoriteStatus(BookItem book, bool isFavorite) async {
+ Future setFavoriteStatus(BookData book, bool isFavorite) async {
await storageClient.setFavoriteStatus(book, isFavorite);
}
@@ -25,7 +26,14 @@ class FavoriteDataSource implements IFavoriteDataSource {
}
@override
- Future> getFavoriteBooks() async {
+ Future> getFavoriteBooks() async {
return await storageClient.getFavoriteBooks();
}
+
+ @override
+ Stream> watchFavoriteBooks() {
+ return storageClient.watchFavoriteBooks().map((favoriteBooks) {
+ return favoriteBooks;
+ });
+ }
}
diff --git a/lib/layers/data/datasource/reading_data_source.dart b/lib/layers/data/datasource/reading_data_source.dart
index b38b211..0d294c9 100644
--- a/lib/layers/data/datasource/reading_data_source.dart
+++ b/lib/layers/data/datasource/reading_data_source.dart
@@ -7,6 +7,7 @@ abstract class IReadingDataSource {
required ReadingData readingData,
});
Future> getReadingData();
+ Stream> watchReadingData();
}
@Injectable(as: IReadingDataSource)
@@ -23,4 +24,10 @@ class ReadingDataSource implements IReadingDataSource {
@override
Future> getReadingData() async =>
await _storageClient.getReadingList();
+
+ @override
+ Stream> watchReadingData() =>
+ _storageClient.watchReadingList().map((reading) {
+ return reading;
+ });
}
diff --git a/lib/layers/data/datasource/search_data_source.dart b/lib/layers/data/datasource/search_data_source.dart
index e022aa9..ea898a2 100644
--- a/lib/layers/data/datasource/search_data_source.dart
+++ b/lib/layers/data/datasource/search_data_source.dart
@@ -7,7 +7,7 @@ abstract class ISearchDataSource {
required String initTitle,
required int startIndex,
});
- Future searchById({
+ Future searchById({
required String id,
});
}
@@ -36,13 +36,13 @@ class SearchDataSource implements ISearchDataSource {
}
@override
- Future searchById({
+ Future searchById({
required String id,
}) async {
final response = await _apiClient.get(
endpoint: 'volumes/$id',
);
- final data = BookItem.fromJson(response);
+ final data = BookData.fromJson(response);
return data;
}
}
diff --git a/lib/layers/data/models/book_list_data.dart b/lib/layers/data/models/book_list_data.dart
index 5300156..54a3f67 100644
--- a/lib/layers/data/models/book_list_data.dart
+++ b/lib/layers/data/models/book_list_data.dart
@@ -8,7 +8,7 @@ class BookListData {
final String kind;
final int? totalItems;
@JsonKey(defaultValue: [])
- final List items;
+ final List items;
BookListData({
required this.kind,
@@ -33,7 +33,7 @@ class BookListData {
}
@JsonSerializable()
-class BookItem {
+class BookData {
final String kind;
final String id;
final String etag;
@@ -43,7 +43,7 @@ class BookItem {
final AccessInfo? accessInfo;
final SearchInfo? searchInfo;
- BookItem({
+ BookData({
required this.kind,
required this.id,
required this.etag,
@@ -54,10 +54,10 @@ class BookItem {
this.searchInfo,
});
- factory BookItem.fromJson(Map json) =>
- _$BookItemFromJson(json);
+ factory BookData.fromJson(Map json) =>
+ _$BookDataFromJson(json);
- Map toJson() => _$BookItemToJson(this);
+ Map toJson() => _$BookDataToJson(this);
BookDomain toDomain() {
return BookDomain(
@@ -71,8 +71,8 @@ class BookItem {
);
}
- factory BookItem.fromDomain(BookDomain domain) {
- return BookItem(
+ factory BookData.fromDomain(BookDomain domain) {
+ return BookData(
kind: domain.kind,
id: domain.id,
etag: '',
diff --git a/lib/layers/data/repository/favorite_repository.dart b/lib/layers/data/repository/favorite_repository.dart
index bb66269..0b98fcc 100644
--- a/lib/layers/data/repository/favorite_repository.dart
+++ b/lib/layers/data/repository/favorite_repository.dart
@@ -22,5 +22,11 @@ class FavoriteRepository implements IFavoriteRepository {
@override
Future setFavoriteStatus(BookDomain book, bool isFavorite) async =>
- _dataSource.setFavoriteStatus(BookItem.fromDomain(book), isFavorite);
+ _dataSource.setFavoriteStatus(BookData.fromDomain(book), isFavorite);
+
+ @override
+ Stream> watchFavoriteBooks() =>
+ _dataSource.watchFavoriteBooks().map((dataBooks) {
+ return dataBooks.map((e) => e.toDomain()).toList();
+ });
}
diff --git a/lib/layers/data/repository/reading_repository.dart b/lib/layers/data/repository/reading_repository.dart
index e0a9903..a9f6b29 100644
--- a/lib/layers/data/repository/reading_repository.dart
+++ b/lib/layers/data/repository/reading_repository.dart
@@ -28,4 +28,14 @@ class ReadingRepository implements IReadingRepository {
final data = await _dataSource.getReadingData();
return data.map((e) => e.toDomainModel()).toList();
}
+
+ @override
+ Stream> watchReadings() =>
+ _dataSource.watchReadingData().map(
+ (dataList) => dataList
+ .map(
+ (e) => e.toDomainModel(),
+ )
+ .toList(),
+ );
}
diff --git a/lib/layers/domain/repository/favorite_repository.dart b/lib/layers/domain/repository/favorite_repository.dart
index f01c3d9..6de9240 100644
--- a/lib/layers/domain/repository/favorite_repository.dart
+++ b/lib/layers/domain/repository/favorite_repository.dart
@@ -4,4 +4,5 @@ abstract class IFavoriteRepository {
Future setFavoriteStatus(BookDomain book, bool isFavorite);
Future getFavoriteStatus(String bookId);
Future> getFavoriteBooks();
+ Stream> watchFavoriteBooks();
}
diff --git a/lib/layers/domain/repository/reading_repository.dart b/lib/layers/domain/repository/reading_repository.dart
index 899ae66..f4f11ab 100644
--- a/lib/layers/domain/repository/reading_repository.dart
+++ b/lib/layers/domain/repository/reading_repository.dart
@@ -5,4 +5,5 @@ abstract class IReadingRepository {
required ReadingDomain reading,
});
Future> getReadings();
+ Stream> watchReadings();
}
diff --git a/lib/layers/domain/usecases/get_readings.dart b/lib/layers/domain/usecases/get_readings.dart
new file mode 100644
index 0000000..97837f2
--- /dev/null
+++ b/lib/layers/domain/usecases/get_readings.dart
@@ -0,0 +1,18 @@
+import 'package:injectable/injectable.dart';
+import 'package:mibook/layers/domain/models/reading_domain.dart';
+import 'package:mibook/layers/domain/repository/reading_repository.dart';
+
+abstract class IGetReadings {
+ Future> call();
+}
+
+@Injectable(as: IGetReadings)
+class GetReadings implements IGetReadings {
+ final IReadingRepository _readingRepository;
+
+ GetReadings(this._readingRepository);
+
+ @override
+ Future> call() async =>
+ await _readingRepository.getReadings();
+}
diff --git a/lib/layers/domain/usecases/watch_favorite.dart b/lib/layers/domain/usecases/watch_favorite.dart
new file mode 100644
index 0000000..ad09b2c
--- /dev/null
+++ b/lib/layers/domain/usecases/watch_favorite.dart
@@ -0,0 +1,17 @@
+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 IWatchFavorite {
+ Stream> call();
+}
+
+@Injectable(as: IWatchFavorite)
+class WatchFavorite implements IWatchFavorite {
+ final IFavoriteRepository _favoriteRepository;
+
+ WatchFavorite(this._favoriteRepository);
+
+ @override
+ Stream> call() => _favoriteRepository.watchFavoriteBooks();
+}
diff --git a/lib/layers/domain/usecases/watch_readings.dart b/lib/layers/domain/usecases/watch_readings.dart
new file mode 100644
index 0000000..c386c50
--- /dev/null
+++ b/lib/layers/domain/usecases/watch_readings.dart
@@ -0,0 +1,17 @@
+import 'package:injectable/injectable.dart';
+import 'package:mibook/layers/domain/models/reading_domain.dart';
+import 'package:mibook/layers/domain/repository/reading_repository.dart';
+
+abstract class IWatchReadings {
+ Stream> call();
+}
+
+@Injectable(as: IWatchReadings)
+class WatchReadings implements IWatchReadings {
+ final IReadingRepository _readingRepository;
+
+ WatchReadings(this._readingRepository);
+
+ @override
+ Stream> call() => _readingRepository.watchReadings();
+}
diff --git a/lib/layers/presentation/screens/bookdetails/book_details_page.dart b/lib/layers/presentation/screens/bookdetails/book_details_page.dart
index 9d06c22..a163c61 100644
--- a/lib/layers/presentation/screens/bookdetails/book_details_page.dart
+++ b/lib/layers/presentation/screens/bookdetails/book_details_page.dart
@@ -14,6 +14,8 @@ import 'package:mibook/layers/presentation/screens/bookdetails/book_details_stat
import 'package:mibook/layers/presentation/screens/bookdetails/book_details_view_model.dart';
import 'package:mibook/layers/presentation/screens/bookdetails/book_details_event.dart';
+const _loading = 'Loading...';
+
@RoutePage()
class BookDetailsPage extends StatelessWidget {
final String id;
@@ -45,7 +47,7 @@ class _BookDetailsScaffold extends StatelessWidget {
child: BlocBuilder(
builder: (context, state) {
return AppNavBar(
- titleText: state.bookDetails?.title ?? 'Loading...',
+ titleText: state.bookDetails?.title ?? _loading,
isTitleLoading: state.isLoading,
onBack: context.router.maybePop,
trailing: Visibility(
@@ -96,27 +98,10 @@ class _BookDetailsContent extends StatelessWidget {
const SizedBox(height: 16),
ListItem(
isExpanded: true,
- input: GenericInput(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- book.title,
- style: TextStyle(
- fontSize: 24,
- fontWeight: FontWeight.bold,
- ),
- ),
- const SizedBox(height: 8),
- if (book.authors.isNotEmpty)
- Text(
- 'By ${book.authors}',
- style: Theme.of(context).textTheme.bodyMedium,
- ),
- const SizedBox(height: 16),
- Html(data: book.description),
- ],
- ),
+ input: TitleSubtitleHtmlDescriptionInput(
+ title: book.title,
+ subtitle: book.authors,
+ description: book.description,
),
),
const SizedBox(height: 24),
diff --git a/lib/layers/presentation/screens/booksearch/book_search_page.dart b/lib/layers/presentation/screens/booksearch/book_search_page.dart
index 15ff8d6..3b4baab 100644
--- a/lib/layers/presentation/screens/booksearch/book_search_page.dart
+++ b/lib/layers/presentation/screens/booksearch/book_search_page.dart
@@ -112,7 +112,7 @@ class _SearchScaffold extends StatelessWidget {
onTap: () => context.router.push(
BookDetailsRoute(id: book.id),
),
- input: BookItemInput(
+ input: TitleImageDescriptionInput(
id: book.id,
kind: book.kind,
title: book.title,
diff --git a/lib/layers/presentation/screens/favoritelist/favorite_item_ui.dart b/lib/layers/presentation/screens/favoritelist/favorite_item_ui.dart
index be29aef..52cb9a9 100644
--- a/lib/layers/presentation/screens/favoritelist/favorite_item_ui.dart
+++ b/lib/layers/presentation/screens/favoritelist/favorite_item_ui.dart
@@ -24,4 +24,15 @@ class FavoriteItemUI with _$FavoriteItemUI {
description: description,
thumbnail: thumbnail,
);
+
+ factory FavoriteItemUI.fromDomain(BookDomain domain) {
+ return FavoriteItemUI(
+ id: domain.id,
+ kind: domain.kind,
+ title: domain.title,
+ authors: domain.authors.join(', '),
+ description: domain.description ?? '',
+ thumbnail: domain.thumbnail,
+ );
+ }
}
diff --git a/lib/layers/presentation/screens/favoritelist/favorite_list_page.dart b/lib/layers/presentation/screens/favoritelist/favorite_list_page.dart
index 41ec5a0..87b5bcc 100644
--- a/lib/layers/presentation/screens/favoritelist/favorite_list_page.dart
+++ b/lib/layers/presentation/screens/favoritelist/favorite_list_page.dart
@@ -10,6 +10,7 @@ import 'package:mibook/layers/presentation/screens/favoritelist/favorite_list_st
import 'package:mibook/layers/presentation/screens/favoritelist/favorite_list_view_model.dart';
typedef _BlocBuilder = BlocBuilder;
+const _title = "Favorite Books";
@RoutePage()
class FavoriteListPage extends StatelessWidget {
@@ -37,53 +38,48 @@ class FavoriteListScaffold extends StatelessWidget {
});
return Scaffold(
appBar: AppNavBar(
- titleText: 'Favorite Books',
+ titleText: _title,
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),
+ return 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),
),
- 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,
- ),
+ input: TitleImageDescriptionInput(
+ 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_state.dart b/lib/layers/presentation/screens/favoritelist/favorite_list_state.dart
index ba82c48..d5e0fba 100644
--- a/lib/layers/presentation/screens/favoritelist/favorite_list_state.dart
+++ b/lib/layers/presentation/screens/favoritelist/favorite_list_state.dart
@@ -6,6 +6,8 @@ part 'favorite_list_state.freezed.dart';
class FavoriteListState with _$FavoriteListState {
const factory FavoriteListState({
@Default([]) List books,
+ @Default(false) bool isLoading,
+ String? errorMessage,
}) = _FavoriteListState;
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
index 2ae1f23..bcd0187 100644
--- a/lib/layers/presentation/screens/favoritelist/favorite_list_view_model.dart
+++ b/lib/layers/presentation/screens/favoritelist/favorite_list_view_model.dart
@@ -1,8 +1,11 @@
+import 'dart:async';
+
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/domain/usecases/watch_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';
@@ -11,16 +14,15 @@ import 'package:mibook/layers/presentation/screens/favoritelist/favorite_list_st
class FavoriteListViewModel extends Bloc {
final IGetFavoriteList _getFavoriteList;
final ISetFavorite _setFavorite;
+ final IWatchFavorite _watchFavorite;
+ StreamSubscription>? _favoriteSubscription;
FavoriteListViewModel(
this._getFavoriteList,
this._setFavorite,
+ this._watchFavorite,
) : super(FavoriteListState.initial()) {
- on((event, emit) async {
- final result = await _loadFavoriteBooks();
- debugPrint('result = ${result.books.map((e) => e.thumbnail).toList()}');
- emit(result);
- });
+ on(_onWatchFavoriteList);
on((event, emit) async {
final result = await _unfavoriteBook(event.bookId);
emit(result);
@@ -32,6 +34,35 @@ class FavoriteListViewModel extends Bloc {
});
}
+ Future _onWatchFavoriteList(
+ FavoriteListEvent event,
+ Emitter emit,
+ ) async {
+ // Cancela subscription anterior se existir
+ await _favoriteSubscription?.cancel();
+
+ // Usa await for para manter o handler ativo
+ try {
+ await for (final favoriteDataList in _watchFavorite()) {
+ final favoriteDomainList = favoriteDataList
+ .map((data) => FavoriteItemUI.fromDomain(data))
+ .toList();
+ emit(
+ state.copyWith(
+ books: favoriteDomainList,
+ ),
+ );
+ }
+ } catch (error) {
+ emit(
+ state.copyWith(
+ errorMessage: error.toString(),
+ isLoading: false,
+ ),
+ );
+ }
+ }
+
Future _loadFavoriteBooks() async {
final favoriteBooks = await _getFavoriteList();
final favoriteItemsUI = favoriteBooks
diff --git a/lib/layers/presentation/screens/readinglist/reading_list_event.dart b/lib/layers/presentation/screens/readinglist/reading_list_event.dart
new file mode 100644
index 0000000..330f0f4
--- /dev/null
+++ b/lib/layers/presentation/screens/readinglist/reading_list_event.dart
@@ -0,0 +1,10 @@
+class ReadingListEvent {}
+
+class WatchReadingListEvent extends ReadingListEvent {}
+
+class RefreshReadingListEvent extends ReadingListEvent {}
+
+class RemoveReadingItemEvent extends ReadingListEvent {
+ final String bookId;
+ RemoveReadingItemEvent(this.bookId);
+}
diff --git a/lib/layers/presentation/screens/readinglist/reading_list_page.dart b/lib/layers/presentation/screens/readinglist/reading_list_page.dart
index 899915d..07cf5b4 100644
--- a/lib/layers/presentation/screens/readinglist/reading_list_page.dart
+++ b/lib/layers/presentation/screens/readinglist/reading_list_page.dart
@@ -1,14 +1,132 @@
-import 'package:auto_route/annotations.dart';
+import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.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';
+import 'package:mibook/core/di/di.dart';
+import 'package:mibook/layers/presentation/screens/readinglist/reading_list_event.dart';
+import 'package:mibook/layers/presentation/screens/readinglist/reading_list_state.dart';
+import 'package:mibook/layers/presentation/screens/readinglist/reading_list_ui.dart';
+import 'package:mibook/layers/presentation/screens/readinglist/reading_list_view_model.dart';
+
+const _title = "Your Reading List";
+const _emptyListMessage = "Your reading list is empty.";
+const _searchBooks = "Search a book";
+const _startIndex = 0;
+const _searchTabIndex = 1;
+
+typedef _BlocBuilder = BlocBuilder;
@RoutePage()
class ReadingListPage extends StatelessWidget {
const ReadingListPage({super.key});
+ @override
+ Widget build(BuildContext context) {
+ return BlocProvider.value(
+ value: getIt(),
+ child: const _ReadingListScaffold(),
+ );
+ }
+}
+
+class _ReadingListScaffold extends StatelessWidget {
+ const _ReadingListScaffold();
+
+ @override
+ Widget build(BuildContext context) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ final viewModel = context.read();
+ viewModel.add(
+ WatchReadingListEvent(),
+ );
+ });
+ return Scaffold(
+ appBar: AppNavBar(
+ titleText: _title,
+ textAlignment: AppNavBarTextAlignment.center,
+ ),
+ body: const _ReadingListContent(),
+ );
+ }
+}
+
+class _ReadingListContent extends StatelessWidget {
+ const _ReadingListContent();
+
+ @override
+ Widget build(BuildContext context) {
+ return _BlocBuilder(
+ builder: (context, state) {
+ if (state.readings.isEmpty) {
+ return _EmptyReadingList();
+ }
+ return Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: _ReadingListSection(
+ title: _title,
+ items: state.readings,
+ ),
+ );
+ },
+ );
+ }
+}
+
+class _EmptyReadingList extends StatelessWidget {
+ const _EmptyReadingList();
+
@override
Widget build(BuildContext context) {
return Center(
- child: Text('Reading List'),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(_emptyListMessage),
+ const SizedBox(height: 12),
+ PrimaryButton(
+ title: _searchBooks,
+ onPressed: () {
+ context.tabsRouter.setActiveIndex(_searchTabIndex);
+ },
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ReadingListSection extends StatelessWidget {
+ final String title;
+ final List items;
+
+ const _ReadingListSection({
+ required this.title,
+ required this.items,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return ListView.separated(
+ itemCount: items.length + 1,
+ separatorBuilder: (context, index) => const SizedBox(height: 12),
+ itemBuilder: (context, index) {
+ if (index == _startIndex) {
+ return Text(
+ title,
+ style: Theme.of(context).textTheme.headlineSmall,
+ );
+ }
+ final item = items[index - 1];
+ return ListItem(
+ input: TitleImageProgressInput(
+ title: item.bookName,
+ progress: item.progress,
+ thumbnail: item.thumbnail,
+ ),
+ );
+ },
);
}
}
diff --git a/lib/layers/presentation/screens/readinglist/reading_list_state.dart b/lib/layers/presentation/screens/readinglist/reading_list_state.dart
new file mode 100644
index 0000000..36a1571
--- /dev/null
+++ b/lib/layers/presentation/screens/readinglist/reading_list_state.dart
@@ -0,0 +1,14 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'package:mibook/layers/presentation/screens/readinglist/reading_list_ui.dart';
+part 'reading_list_state.freezed.dart';
+
+@freezed
+class ReadingListState with _$ReadingListState {
+ const ReadingListState._();
+
+ const factory ReadingListState({
+ @Default(false) bool isLoading,
+ @Default([]) List readings,
+ @Default('') String errorMessage,
+ }) = _ReadingListState;
+}
diff --git a/lib/layers/presentation/screens/readinglist/reading_list_ui.dart b/lib/layers/presentation/screens/readinglist/reading_list_ui.dart
new file mode 100644
index 0000000..47e8c8a
--- /dev/null
+++ b/lib/layers/presentation/screens/readinglist/reading_list_ui.dart
@@ -0,0 +1,26 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'package:mibook/layers/domain/models/reading_domain.dart';
+part 'reading_list_ui.freezed.dart';
+
+@freezed
+class ReadingUI with _$ReadingUI {
+ const ReadingUI._();
+
+ const factory ReadingUI({
+ @Default('') bookId,
+ @Default('') bookName,
+ @Default(null) bookThumb,
+ @Default(0.0) progress,
+ thumbnail,
+ }) = _ReadingUI;
+
+ factory ReadingUI.fromDomain(ReadingDomain domain) {
+ return ReadingUI(
+ bookId: domain.bookId,
+ bookName: domain.bookName,
+ bookThumb: domain.bookThumb,
+ progress: domain.progress,
+ thumbnail: domain.bookThumb,
+ );
+ }
+}
diff --git a/lib/layers/presentation/screens/readinglist/reading_list_view_model.dart b/lib/layers/presentation/screens/readinglist/reading_list_view_model.dart
new file mode 100644
index 0000000..918cafa
--- /dev/null
+++ b/lib/layers/presentation/screens/readinglist/reading_list_view_model.dart
@@ -0,0 +1,102 @@
+import 'dart:async';
+
+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_readings.dart';
+import 'package:mibook/layers/domain/usecases/watch_readings.dart';
+import 'package:mibook/layers/presentation/screens/readinglist/reading_list_event.dart';
+import 'package:mibook/layers/presentation/screens/readinglist/reading_list_state.dart';
+import 'package:mibook/layers/presentation/screens/readinglist/reading_list_ui.dart';
+
+@injectable
+class ReadingListViewModel extends Bloc {
+ final IGetReadings _getReadings;
+ final IWatchReadings _watchReadings;
+
+ StreamSubscription>? _readingsSubscription;
+
+ ReadingListViewModel(
+ this._getReadings,
+ this._watchReadings,
+ ) : super(ReadingListState()) {
+ on(_onWatchReadingList);
+ on(_onRefreshReadingList);
+ on(_onRemoveReadingItem);
+ }
+
+ Future _onWatchReadingList(
+ WatchReadingListEvent event,
+ Emitter emit,
+ ) async {
+ // Cancela subscription anterior se existir
+ await _readingsSubscription?.cancel();
+
+ // Usa await for para manter o handler ativo
+ try {
+ await for (final readingDataList in _watchReadings()) {
+ final readingDomainList = readingDataList
+ .map((data) => ReadingUI.fromDomain(data))
+ .toList();
+ emit(
+ state.copyWith(
+ readings: readingDomainList,
+ isLoading: false,
+ errorMessage: '',
+ ),
+ );
+ }
+ } catch (error) {
+ emit(
+ state.copyWith(
+ errorMessage: error.toString(),
+ isLoading: false,
+ ),
+ );
+ }
+ }
+
+ void _onRefreshReadingList(
+ RefreshReadingListEvent event,
+ Emitter emit,
+ ) async {
+ // Implement refresh logic here
+ // For example, you could re-emit the current state or fetch fresh data
+ emit(state.copyWith(isLoading: true));
+
+ try {
+ final readings = await _getReadings();
+ emit(
+ state.copyWith(
+ readings: readings.map((e) => ReadingUI.fromDomain(e)).toList(),
+ isLoading: false,
+ ),
+ );
+ } catch (e) {
+ emit(
+ state.copyWith(
+ errorMessage: e.toString(),
+ isLoading: false,
+ ),
+ );
+ }
+ }
+
+ void _onRemoveReadingItem(
+ RemoveReadingItemEvent event,
+ Emitter emit,
+ ) async {
+ // Implement remove logic here
+ // For example, remove the item from the current state
+ final updatedReadings = state.readings
+ .where((reading) => reading.bookId != event.bookId)
+ .toList();
+ emit(state.copyWith(readings: updatedReadings));
+ }
+
+ @override
+ Future close() {
+ _readingsSubscription?.cancel();
+ return super.close();
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
index 21cd845..d423dab 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,10 +1,18 @@
import 'package:flutter/material.dart';
import 'package:mibook/core/routes/app_router.dart';
import 'package:mibook/core/di/di.dart';
+import 'package:mibook/layers/data/api/storage_client.dart';
-void main() async {
+void setUpInitialization() {
WidgetsFlutterBinding.ensureInitialized();
configureDependencies();
+ final storageClient = getIt();
+ storageClient.startReadingListWatcher();
+ storageClient.startFavoriteBooksWatcher();
+}
+
+void main() async {
+ setUpInitialization();
runApp(CoreApplication());
}
diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..21a3cc1
--- /dev/null
+++ b/macos/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/pubspec.lock b/pubspec.lock
index e470d5c..6a1a8f2 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -5,18 +5,34 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
- sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
+ sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
url: "https://pub.dev"
source: hosted
- version: "67.0.0"
+ version: "93.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
- sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
+ sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
url: "https://pub.dev"
source: hosted
- version: "6.4.1"
+ version: "10.0.1"
+ ansicolor:
+ dependency: transitive
+ description:
+ name: ansicolor
+ sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.3"
+ archive:
+ dependency: transitive
+ description:
+ name: archive
+ sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.0.9"
args:
dependency: transitive
description:
@@ -45,18 +61,18 @@ packages:
dependency: "direct main"
description:
name: auto_route
- sha256: b83e8ce46da7228cdd019b5a11205454847f0a971bca59a7529b98df9876889b
+ sha256: e9acfeb3df33d188fce4ad0239ef4238f333b7aa4d95ec52af3c2b9360dcd969
url: "https://pub.dev"
source: hosted
- version: "9.2.2"
+ version: "11.1.0"
auto_route_generator:
- dependency: "direct main"
+ dependency: "direct dev"
description:
name: auto_route_generator
- sha256: c9086eb07271e51b44071ad5cff34e889f3156710b964a308c2ab590769e79e6
+ sha256: "7aa0e90874928e78709f0a21a69fb5bc2ae1aa932dec862930d2af85c40adb01"
url: "https://pub.dev"
source: hosted
- version: "9.0.0"
+ version: "10.5.0"
auto_size_text:
dependency: "direct main"
description:
@@ -69,10 +85,18 @@ packages:
dependency: transitive
description:
name: bloc
- sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e"
+ sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81
url: "https://pub.dev"
source: hosted
- version: "8.1.4"
+ version: "9.2.0"
+ bloc_test:
+ dependency: "direct dev"
+ description:
+ name: bloc_test
+ sha256: "1dd549e58be35148bc22a9135962106aa29334bc1e3f285994946a1057b29d7b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "10.0.0"
boolean_selector:
dependency: transitive
description:
@@ -85,18 +109,18 @@ packages:
dependency: transitive
description:
name: build
- sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
+ sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c
url: "https://pub.dev"
source: hosted
- version: "2.4.1"
+ version: "4.0.5"
build_config:
dependency: transitive
description:
name: build_config
- sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
+ sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
url: "https://pub.dev"
source: hosted
- version: "1.1.2"
+ version: "1.3.0"
build_daemon:
dependency: transitive
description:
@@ -105,30 +129,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.4"
- build_resolvers:
- dependency: transitive
- description:
- name: build_resolvers
- sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a"
- url: "https://pub.dev"
- source: hosted
- version: "2.4.2"
build_runner:
dependency: "direct dev"
description:
name: build_runner
- sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
- url: "https://pub.dev"
- source: hosted
- version: "2.4.13"
- build_runner_core:
- dependency: transitive
- description:
- name: build_runner_core
- sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
+ sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e"
url: "https://pub.dev"
source: hosted
- version: "7.3.2"
+ version: "2.13.1"
built_collection:
dependency: transitive
description:
@@ -173,10 +181,10 @@ packages:
dependency: transitive
description:
name: characters
- sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
+ sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
- version: "1.4.0"
+ version: "1.4.1"
checked_yaml:
dependency: transitive
description:
@@ -193,6 +201,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.0"
+ cli_util:
+ dependency: transitive
+ description:
+ name: cli_util
+ sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.4.2"
clock:
dependency: transitive
description:
@@ -205,10 +221,10 @@ packages:
dependency: transitive
description:
name: code_builder
- sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e"
+ sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
- version: "4.10.1"
+ version: "4.11.1"
collection:
dependency: transitive
description:
@@ -261,18 +277,26 @@ packages:
dependency: transitive
description:
name: dart_style
- sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9"
+ sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
url: "https://pub.dev"
source: hosted
- version: "2.3.6"
+ version: "3.1.7"
+ diff_match_patch:
+ dependency: transitive
+ description:
+ name: diff_match_patch
+ sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.4.1"
dio:
dependency: "direct main"
description:
name: dio
- sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260"
+ sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
url: "https://pub.dev"
source: hosted
- version: "5.7.0"
+ version: "5.9.2"
dio_web_adapter:
dependency: transitive
description:
@@ -338,10 +362,10 @@ packages:
dependency: "direct main"
description:
name: flutter_bloc
- sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a
+ sha256: cf51747952201a455a1c840f8171d273be009b932c75093020f9af64f2123e38
url: "https://pub.dev"
source: hosted
- version: "8.1.6"
+ version: "9.1.1"
flutter_cache_manager:
dependency: transitive
description:
@@ -358,14 +382,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
+ flutter_launcher_icons:
+ dependency: "direct dev"
+ description:
+ name: flutter_launcher_icons
+ sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.13.1"
flutter_lints:
dependency: "direct main"
description:
name: flutter_lints
- sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
+ sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
- version: "5.0.0"
+ version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -380,18 +412,18 @@ packages:
dependency: "direct main"
description:
name: freezed
- sha256: a434911f643466d78462625df76fd9eb13e57348ff43fe1f77bbe909522c67a1
+ sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131
url: "https://pub.dev"
source: hosted
- version: "2.5.2"
+ version: "3.2.5"
freezed_annotation:
dependency: "direct main"
description:
name: freezed_annotation
- sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
+ sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
url: "https://pub.dev"
source: hosted
- version: "2.4.4"
+ version: "3.1.0"
frontend_server_client:
dependency: transitive
description:
@@ -404,10 +436,10 @@ packages:
dependency: "direct main"
description:
name: get_it
- sha256: c49895c1ecb0ee2a0ec568d39de882e2c299ba26355aa6744ab1001f98cebd15
+ sha256: "568d62f0e68666fb5d95519743b3c24a34c7f19d834b0658c46e26d778461f66"
url: "https://pub.dev"
source: hosted
- version: "8.0.2"
+ version: "9.2.1"
glob:
dependency: transitive
description:
@@ -424,6 +456,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
+ hotreloader:
+ dependency: transitive
+ description:
+ name: hotreloader
+ sha256: bc167a1163807b03bada490bfe2df25b0d744df359227880220a5cbd04e5734b
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.3.0"
html:
dependency: transitive
description:
@@ -456,22 +496,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
+ image:
+ dependency: transitive
+ description:
+ name: image
+ sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.8.0"
injectable:
dependency: "direct main"
description:
name: injectable
- sha256: "5e1556ea1d374fe44cbe846414d9bab346285d3d8a1da5877c01ad0774006068"
+ sha256: "32b36a9d87f18662bee0b1951b81f47a01f2bf28cd6ea94f60bc5453c7bf598c"
url: "https://pub.dev"
source: hosted
- version: "2.5.0"
+ version: "2.7.1+4"
injectable_generator:
dependency: "direct dev"
description:
name: injectable_generator
- sha256: af403d76c7b18b4217335e0075e950cd0579fd7f8d7bd47ee7c85ada31680ba1
+ sha256: fcc0d1edcef2c863dec846b0dec27cb2390d9e9b62e61da5cfc2b9a06b9533c2
url: "https://pub.dev"
source: hosted
- version: "2.6.2"
+ version: "2.12.1"
io:
dependency: transitive
description:
@@ -492,18 +540,18 @@ packages:
dependency: "direct main"
description:
name: json_annotation
- sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
+ sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
- version: "4.9.0"
+ version: "4.11.0"
json_serializable:
dependency: "direct main"
description:
name: json_serializable
- sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b
+ sha256: fbcf404b03520e6e795f6b9b39badb2b788407dfc0a50cf39158a6ae1ca78925
url: "https://pub.dev"
source: hosted
- version: "6.8.0"
+ version: "6.13.1"
leak_tracker:
dependency: transitive
description:
@@ -528,14 +576,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
+ lean_builder:
+ dependency: transitive
+ description:
+ name: lean_builder
+ sha256: ee4117b03e93a4eb83e1a78c8e7a1dc22188d43bb142309982be48673a1b3a53
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.1.7"
lints:
dependency: transitive
description:
name: lints
- sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
+ sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
- version: "5.1.1"
+ version: "6.1.0"
list_counter:
dependency: transitive
description:
@@ -556,26 +612,26 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
+ sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
- version: "0.12.17"
+ version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
+ sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
- version: "0.11.1"
+ version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
- sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
+ sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
- version: "1.16.0"
+ version: "1.17.0"
mime:
dependency: transitive
description:
@@ -588,10 +644,18 @@ packages:
dependency: "direct main"
description:
name: mockito
- sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917"
+ sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566
url: "https://pub.dev"
source: hosted
- version: "5.4.4"
+ version: "5.6.3"
+ mocktail:
+ dependency: transitive
+ description:
+ name: mocktail
+ sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.4"
nested:
dependency: transitive
description:
@@ -684,10 +748,10 @@ packages:
dependency: transitive
description:
name: petitparser
- sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
+ sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
- version: "6.1.0"
+ version: "7.0.2"
platform:
dependency: transitive
description:
@@ -720,6 +784,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.1"
+ posix:
+ dependency: transitive
+ description:
+ name: posix
+ sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.5.0"
provider:
dependency: transitive
description:
@@ -764,10 +836,10 @@ packages:
dependency: "direct main"
description:
name: shared_preferences
- sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
+ sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
url: "https://pub.dev"
source: hosted
- version: "2.5.3"
+ version: "2.5.4"
shared_preferences_android:
dependency: transitive
description:
@@ -865,18 +937,18 @@ packages:
dependency: transitive
description:
name: source_gen
- sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
+ sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd"
url: "https://pub.dev"
source: hosted
- version: "1.5.0"
+ version: "4.2.2"
source_helper:
dependency: transitive
description:
name: source_helper
- sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c"
+ sha256: "1d3b229b2934034fb2e691fbb3d53e0f75a4af7b1407f88425ed8f209bcb1b8f"
url: "https://pub.dev"
source: hosted
- version: "1.3.5"
+ version: "1.3.11"
source_map_stack_trace:
dependency: transitive
description:
@@ -897,10 +969,10 @@ packages:
dependency: transitive
description:
name: source_span
- sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
+ sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
- version: "1.10.1"
+ version: "1.10.2"
sprintf:
dependency: transitive
description:
@@ -1001,34 +1073,26 @@ packages:
dependency: "direct dev"
description:
name: test
- sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
+ sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a"
url: "https://pub.dev"
source: hosted
- version: "1.26.2"
+ version: "1.29.0"
test_api:
dependency: transitive
description:
name: test_api
- sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
+ sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
- version: "0.7.6"
+ version: "0.7.9"
test_core:
dependency: transitive
description:
name: test_core
- sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a"
+ sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943"
url: "https://pub.dev"
source: hosted
- version: "0.6.11"
- timing:
- dependency: transitive
- description:
- name: timing
- sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
- url: "https://pub.dev"
- source: hosted
- version: "1.0.2"
+ version: "0.6.15"
typed_data:
dependency: transitive
description:
@@ -1065,10 +1129,10 @@ packages:
dependency: transitive
description:
name: watcher
- sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a"
+ sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
- version: "1.1.2"
+ version: "1.2.1"
web:
dependency: transitive
description:
@@ -1113,10 +1177,18 @@ packages:
dependency: transitive
description:
name: xml
- sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
+ sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
- version: "6.5.0"
+ version: "6.6.1"
+ xxh3:
+ dependency: transitive
+ description:
+ name: xxh3
+ sha256: "399a0438f5d426785723c99da6b16e136f4953fb1e9db0bf270bd41dd4619916"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.0"
yaml:
dependency: transitive
description:
@@ -1126,5 +1198,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
- dart: ">=3.8.1 <4.0.0"
- flutter: ">=3.27.0"
+ dart: ">=3.10.0 <4.0.0"
+ flutter: ">=3.35.0"
diff --git a/pubspec.yaml b/pubspec.yaml
index d097a30..20f089b 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,8 +1,6 @@
name: mibook
description: "Project that works as a Reading Manager for avid readers"
-# The following line prevents the package from being accidentally published to
-# pub.dev using `flutter pub publish`. This is preferred for private packages.
-publish_to: "none" # Remove this line if you wish to publish to pub.dev
+publish_to: "none"
version: 1.0.0+1
@@ -12,36 +10,35 @@ environment:
dependencies:
flutter:
sdk: flutter
-
cupertino_icons: 1.0.8
- flutter_lints: 5.0.0
- get_it: 8.0.2
- injectable: 2.5.0
- json_serializable: 6.8.0
- json_annotation: 4.9.0
- auto_route: 9.2.2
+ flutter_lints: ^6.0.0
+ get_it: ^9.2.1
+ injectable: ^2.7.1+4
+ json_serializable: ^6.13.1
+ json_annotation: ^4.11.0
+ auto_route: ^11.1.0
cached_network_image: 3.4.1
- dio: 5.7.0
- freezed: ^2.5.2
- freezed_annotation: ^2.4.1
+ dio: ^5.9.2
+ freezed: ^3.2.5
+ freezed_annotation: ^3.1.0
encrypted_shared_preferences: 3.0.1
- flutter_bloc: 8.1.6
- mockito: 5.4.4
+ flutter_bloc: ^9.1.1
+ mockito: ^5.6.3
shimmer: 3.0.0
flutter_html: 3.0.0
- shared_preferences: 2.5.3
+ shared_preferences: ^2.5.4
auto_size_text: 3.0.0
path_provider: ^2.1.5
- auto_route_generator: ^9.0.0
dev_dependencies:
flutter_test:
sdk: flutter
-
build_runner: ^2.4.13
injectable_generator: ^2.6.2
-
+ auto_route_generator: ^10.5.0
+ bloc_test: ^10.0.0
test: ^1.25.2
+ flutter_launcher_icons: ^0.13.1
targets:
$default:
@@ -52,34 +49,10 @@ targets:
flutter:
uses-material-design: true
+ assets:
+ - images/empty-list.png
- # To add assets to your application, add an assets section, like this:
- # assets:
- # - images/a_dot_burr.jpeg
- # - images/a_dot_ham.jpeg
-
- # An image asset can refer to one or more resolution-specific "variants", see
- # https://flutter.dev/to/resolution-aware-images
-
- # For details regarding adding assets from package dependencies, see
- # https://flutter.dev/to/asset-from-package
-
- # To add custom fonts to your application, add a fonts section here,
- # in this "flutter" section. Each entry in this list should have a
- # "family" key with the font family name, and a "fonts" key with a
- # list giving the asset and other descriptors for the font. For
- # example:
- # fonts:
- # - family: Schyler
- # fonts:
- # - asset: fonts/Schyler-Regular.ttf
- # - asset: fonts/Schyler-Italic.ttf
- # style: italic
- # - family: Trajan Pro
- # fonts:
- # - asset: fonts/TrajanPro.ttf
- # - asset: fonts/TrajanPro_Bold.ttf
- # weight: 700
- #
- # For details regarding fonts from package dependencies,
- # see https://flutter.dev/to/font-from-package
+flutter_launcher_icons:
+ ios: true
+ image_path: "images/icons/mibook_icon.png"
+ remove_alpha_ios: true
diff --git a/test/layers/data/datasource/fake_book_item.dart b/test/layers/data/datasource/fake_book_item.dart
index ec40d41..d9f5340 100644
--- a/test/layers/data/datasource/fake_book_item.dart
+++ b/test/layers/data/datasource/fake_book_item.dart
@@ -1,6 +1,6 @@
import 'package:mibook/layers/data/models/book_list_data.dart';
-final fakeBookItem = BookItem(
+final fakeBookItem = BookData(
kind: 'fiction',
id: 'id',
etag: 'tag',
diff --git a/test/layers/data/datasource/favorite_data_source_test.dart b/test/layers/data/datasource/favorite_data_source_test.dart
index f5eb058..176e6ec 100644
--- a/test/layers/data/datasource/favorite_data_source_test.dart
+++ b/test/layers/data/datasource/favorite_data_source_test.dart
@@ -1,6 +1,7 @@
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:mibook/layers/data/models/book_list_data.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
@@ -39,5 +40,40 @@ void main() {
verify(storageClient.getFavoriteBooks()).called(1);
expect(result, fakeBooks);
});
+
+ test(
+ 'watchFavoriteBooks emits favorite books from storageClient',
+ () async {
+ // Arrange
+ final fakeBooks = [fakeBookItem]; // ajuste se necessário
+ when(
+ storageClient.watchFavoriteBooks(),
+ ).thenAnswer((_) => Stream.value(fakeBooks));
+
+ // Act
+ final stream = sut.watchFavoriteBooks();
+
+ // Assert
+ await expectLater(stream, emits(fakeBooks));
+ verify(storageClient.watchFavoriteBooks()).called(1);
+ verifyNoMoreInteractions(storageClient);
+ },
+ );
+
+ test('watchFavoriteBooks propagates errors from storageClient', () async {
+ // Arrange
+ final error = Exception('storage error');
+ when(storageClient.watchFavoriteBooks()).thenAnswer(
+ (_) => Stream>.error(error),
+ ); // ajuste se necessário
+
+ // Act
+ final stream = sut.watchFavoriteBooks();
+
+ // Assert
+ await expectLater(stream, emitsError(error));
+ verify(storageClient.watchFavoriteBooks()).called(1);
+ verifyNoMoreInteractions(storageClient);
+ });
});
}
diff --git a/test/layers/data/datasource/reading_data_source_test.dart b/test/layers/data/datasource/reading_data_source_test.dart
index 944cfd9..51a8eee 100644
--- a/test/layers/data/datasource/reading_data_source_test.dart
+++ b/test/layers/data/datasource/reading_data_source_test.dart
@@ -11,10 +11,22 @@ import 'reading_data_source_test.mocks.dart';
void main() {
late MockIStorageClient storageClient;
late ReadingDataSource sut;
+ late Stream> mockStream;
setUp() {
storageClient = MockIStorageClient();
sut = ReadingDataSource(storageClient);
+ final fakeListData = [
+ ReadingData(
+ 'id',
+ 'Harry Potter',
+ 'image',
+ 0.5,
+ ),
+ ];
+ final Iterable> fakeIterableReadingData = [fakeListData];
+ mockStream = Stream.fromIterable(fakeIterableReadingData);
+ when(storageClient.watchReadingList()).thenAnswer((_) => mockStream);
}
group('ReadingDataSource', () {
@@ -33,5 +45,34 @@ void main() {
verify(storageClient.getReadingList()).called(1);
expect(result, fakeData);
});
+
+ test('should return the stream from storage client', () async {
+ // Act
+ final result = sut.watchReadingData();
+
+ // Assert
+ expect(result, equals(mockStream));
+ verify(storageClient.watchReadingList()).called(1);
+ verifyNoMoreInteractions(storageClient);
+ });
+
+ test('should emit the same values as the storage client stream', () async {
+ // Arrange
+ final emittedValues = >[];
+
+ // Act
+ final subscription = sut.watchReadingData().listen(
+ (data) => emittedValues.add(data),
+ );
+
+ // // Wait for stream to complete
+ await Future.delayed(Duration.zero);
+ await subscription.cancel();
+
+ // Assert
+ expect(emittedValues.length, equals(1));
+ expect(emittedValues[0].first.bookId, equals('id'));
+ verify(storageClient.watchReadingList()).called(1);
+ });
});
}
diff --git a/test/layers/data/repository/favorite_repository_test.dart b/test/layers/data/repository/favorite_repository_test.dart
index f5f8563..3b64c8c 100644
--- a/test/layers/data/repository/favorite_repository_test.dart
+++ b/test/layers/data/repository/favorite_repository_test.dart
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.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/data/repository/favorite_repository.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
@@ -49,4 +50,23 @@ void main() {
).called(1);
});
});
+
+ test(
+ 'watchFavoriteBooks emits favorite books from data source',
+ () async {
+ // Arrange
+ final fakeBooks = [fakeBookItem]; // ajuste se necessário
+ when(
+ mockFavoriteDataSource.watchFavoriteBooks(),
+ ).thenAnswer((_) => Stream.value(fakeBooks));
+
+ // Act
+ final stream = sut.watchFavoriteBooks();
+
+ // Assert
+ await expectLater(stream, emits([fakeBookDomain]));
+ verify(mockFavoriteDataSource.watchFavoriteBooks()).called(1);
+ verifyNoMoreInteractions(mockFavoriteDataSource);
+ },
+ );
}
diff --git a/test/layers/data/repository/reading_repository_test.dart b/test/layers/data/repository/reading_repository_test.dart
index 32fdea9..ea1fe19 100644
--- a/test/layers/data/repository/reading_repository_test.dart
+++ b/test/layers/data/repository/reading_repository_test.dart
@@ -12,10 +12,26 @@ import 'reading_repository_test.mocks.dart';
void main() {
late MockIReadingDataSource mockIReadingDataSource;
late ReadingRepository sut;
+ late Stream> mockStream;
setUp() {
mockIReadingDataSource = MockIReadingDataSource();
sut = ReadingRepository(mockIReadingDataSource);
+
+ // Create mock ReadingData objects
+ final readingData1 = ReadingData('1', 'Book 1', 'thumb1.jpg', 0.5);
+ final readingData2 = ReadingData('2', 'Book 2', 'thumb2.jpg', 0.8);
+
+ // Create a mock stream that emits lists of ReadingData
+ mockStream = Stream.fromIterable([
+ [readingData1],
+ [readingData1, readingData2],
+ ]);
+
+ // Stub the watchReadingData method to return our mock stream
+ when(
+ mockIReadingDataSource.watchReadingData(),
+ ).thenAnswer((_) => mockStream);
}
group('test ReadingRepository', () {
@@ -66,4 +82,119 @@ void main() {
verify(mockIReadingDataSource.getReadingData()).called(1);
expect(response.length, 2);
});
+
+ group('ReadingRepository - watchReadings', () {
+ test(
+ 'should return a stream that maps ReadingData to ReadingDomain',
+ () async {
+ // Act
+ final result = sut.watchReadings();
+
+ // Assert
+ expect(result, isA>>());
+ verify(mockIReadingDataSource.watchReadingData()).called(1);
+ verifyNoMoreInteractions(mockIReadingDataSource);
+ },
+ );
+
+ test(
+ 'should emit mapped ReadingDomain objects from ReadingData stream',
+ () async {
+ // Arrange
+ final emittedValues = >[];
+
+ // Act
+ final subscription = sut.watchReadings().listen(
+ (data) => emittedValues.add(data),
+ );
+
+ // Wait for stream to complete
+ await Future.delayed(Duration.zero);
+ await subscription.cancel();
+
+ // Assert
+ expect(emittedValues.length, equals(2));
+
+ // First emission: [ReadingData('1', 'Book 1', 'thumb1.jpg', 0.5)] -> [ReadingDomain]
+ expect(emittedValues[0].length, equals(1));
+ expect(emittedValues[0][0].bookId, equals('1'));
+ expect(emittedValues[0][0].bookName, equals('Book 1'));
+
+ // Second emission: [ReadingData('1', ...), ReadingData('2', ...)] -> [ReadingDomain, ReadingDomain]
+ expect(emittedValues[1].length, equals(2));
+ expect(emittedValues[1][0].bookId, equals('1'));
+ expect(emittedValues[1][1].bookId, equals('2'));
+
+ verify(mockIReadingDataSource.watchReadingData()).called(1);
+ },
+ );
+
+ test('should handle errors from data source stream', () async {
+ // Arrange
+ final errorStream = Stream>.error(
+ Exception('Data source error'),
+ );
+ when(
+ mockIReadingDataSource.watchReadingData(),
+ ).thenAnswer((_) => errorStream);
+
+ // Act & Assert
+ expect(
+ () => sut.watchReadings().first,
+ throwsA(isA()),
+ );
+ verify(mockIReadingDataSource.watchReadingData()).called(1);
+ });
+
+ test('should handle empty lists from data source', () async {
+ // Arrange
+ final emptyStream = Stream.fromIterable([
+ [],
+ [],
+ ]);
+ when(
+ mockIReadingDataSource.watchReadingData(),
+ ).thenAnswer((_) => emptyStream);
+
+ // Act
+ final emittedValues = >[];
+ final subscription = sut.watchReadings().listen(
+ (data) => emittedValues.add(data),
+ );
+
+ await Future.delayed(Duration.zero);
+ await subscription.cancel();
+
+ // Assert
+ expect(emittedValues.length, equals(2));
+ expect(emittedValues[0], isEmpty);
+ expect(emittedValues[1], isEmpty);
+ verify(mockIReadingDataSource.watchReadingData()).called(1);
+ });
+
+ test(
+ 'should transform single ReadingData to single ReadingDomain',
+ () async {
+ // Arrange
+ final singleData = ReadingData('3', 'Book 3', 'thumb3.jpg', 0.3);
+ final singleStream = Stream.fromIterable([
+ [singleData],
+ ]);
+ when(
+ mockIReadingDataSource.watchReadingData(),
+ ).thenAnswer((_) => singleStream);
+
+ // Act
+ final result = await sut.watchReadings().first;
+
+ // Assert
+ expect(result.length, equals(1));
+ expect(result[0].bookId, equals('3'));
+ expect(result[0].bookName, equals('Book 3'));
+ expect(result[0].bookThumb, equals('thumb3.jpg'));
+ expect(result[0].progress, equals(0.3));
+ verify(mockIReadingDataSource.watchReadingData()).called(1);
+ },
+ );
+ });
}
diff --git a/test/layers/domain/fakes/fake_reading_domain.dart b/test/layers/domain/fakes/fake_reading_domain.dart
new file mode 100644
index 0000000..e8483bd
--- /dev/null
+++ b/test/layers/domain/fakes/fake_reading_domain.dart
@@ -0,0 +1,94 @@
+import 'package:mibook/layers/domain/models/reading_domain.dart';
+
+final fakeReadingDomains = [
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440000',
+ bookName: 'To Kill a Mockingbird',
+ bookThumb: 'https://example.com/thumb1.jpg',
+ progress: 1.0,
+ ),
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440001',
+ bookName: '1984',
+ bookThumb: null,
+ progress: 0.75,
+ ),
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440002',
+ bookName: 'Pride and Prejudice',
+ bookThumb: 'https://example.com/thumb2.jpg',
+ progress: 0.0,
+ ),
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440003',
+ bookName: 'The Great Gatsby',
+ bookThumb: null,
+ progress: 0.5,
+ ),
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440004',
+ bookName: 'Dune',
+ bookThumb: 'https://example.com/thumb3.jpg',
+ progress: 0.9,
+ ),
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440005',
+ bookName: "Harry Potter and the Sorcerer's Stone",
+ bookThumb: null,
+ progress: 0.1,
+ ),
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440006',
+ bookName: 'The Catcher in the Rye',
+ bookThumb: 'https://example.com/thumb4.jpg',
+ progress: 1.0,
+ ),
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440007',
+ bookName: 'Lord of the Rings: The Fellowship of the Ring',
+ bookThumb: null,
+ progress: 0.6,
+ ),
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440008',
+ bookName: 'Jane Eyre',
+ bookThumb: 'https://example.com/thumb5.jpg',
+ progress: 0.0,
+ ),
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440009',
+ bookName: 'Neuromancer',
+ bookThumb: null,
+ progress: 0.8,
+ ),
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440010',
+ bookName: 'The Hobbit',
+ bookThumb: 'https://example.com/thumb6.jpg',
+ progress: 0.2,
+ ),
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440011',
+ bookName: 'Brave New World',
+ bookThumb: null,
+ progress: 1.0,
+ ),
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440012',
+ bookName: 'The Alchemist',
+ bookThumb: 'https://example.com/thumb7.jpg',
+ progress: 0.4,
+ ),
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440013',
+ bookName: 'Fahrenheit 451',
+ bookThumb: null,
+ progress: 0.7,
+ ),
+ ReadingDomain(
+ bookId: '550e8400-e29b-41d4-a716-446655440014',
+ bookName: "The Hitchhiker's Guide to the Galaxy",
+ bookThumb: 'https://example.com/thumb8.jpg',
+ progress: 0.0,
+ ),
+];
diff --git a/test/layers/domain/get_readings_test.dart b/test/layers/domain/get_readings_test.dart
new file mode 100644
index 0000000..e9b2401
--- /dev/null
+++ b/test/layers/domain/get_readings_test.dart
@@ -0,0 +1,82 @@
+@GenerateNiceMocks([MockSpec()])
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mibook/layers/domain/repository/reading_repository.dart';
+import 'package:mibook/layers/domain/usecases/get_readings.dart';
+import 'package:mockito/annotations.dart';
+import 'package:mockito/mockito.dart';
+
+import 'fakes/fake_reading_domain.dart';
+import 'get_readings_test.mocks.dart';
+
+void main() {
+ late MockIReadingRepository mockRepository;
+ late GetReadings getReadings;
+
+ setUp(() {
+ mockRepository = MockIReadingRepository();
+ getReadings = GetReadings(mockRepository);
+ });
+
+ group('GetReadings', () {
+ test('should return the expected list on successful call', () async {
+ // Arrange
+ when(
+ mockRepository.getReadings(),
+ ).thenAnswer((_) async => fakeReadingDomains);
+
+ // Act
+ final result = await getReadings.call();
+
+ // Assert
+ expect(result, equals(fakeReadingDomains));
+ verify(mockRepository.getReadings()).called(1);
+ verifyNoMoreInteractions(mockRepository);
+ });
+
+ test('should throw exception when repository throws', () async {
+ // Arrange
+ final exception = Exception('Repository error');
+ when(mockRepository.getReadings()).thenThrow(exception);
+
+ // Act & Assert
+ expect(() async => await getReadings.call(), throwsA(exception));
+ verify(mockRepository.getReadings()).called(1);
+ verifyNoMoreInteractions(mockRepository);
+ });
+
+ test(
+ 'should return empty list when repository returns empty list',
+ () async {
+ // Arrange
+ when(mockRepository.getReadings()).thenAnswer((_) async => []);
+
+ // Act
+ final result = await getReadings.call();
+
+ // Assert
+ expect(result, isEmpty);
+ verify(mockRepository.getReadings()).called(1);
+ verifyNoMoreInteractions(mockRepository);
+ },
+ );
+
+ test('should handle multiple calls without side effects', () async {
+ // Arrange
+ when(
+ mockRepository.getReadings(),
+ ).thenAnswer((_) async => fakeReadingDomains);
+
+ // Act
+ final result1 = await getReadings.call();
+ final result2 = await getReadings.call();
+ final result3 = await getReadings.call();
+
+ // Assert
+ expect(result1, equals(fakeReadingDomains));
+ expect(result2, equals(fakeReadingDomains));
+ expect(result3, equals(fakeReadingDomains));
+ verify(mockRepository.getReadings()).called(3);
+ verifyNoMoreInteractions(mockRepository);
+ });
+ });
+}
diff --git a/test/layers/domain/watch_favorite_test.dart b/test/layers/domain/watch_favorite_test.dart
new file mode 100644
index 0000000..d8ca7c4
--- /dev/null
+++ b/test/layers/domain/watch_favorite_test.dart
@@ -0,0 +1,76 @@
+import 'package:mibook/layers/domain/models/book_list_domain.dart';
+import 'package:mibook/layers/domain/repository/favorite_repository.dart';
+import 'package:mockito/annotations.dart';
+
+@GenerateNiceMocks([MockSpec()])
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/mockito.dart';
+import 'watch_favorite_test.mocks.dart';
+
+void main() {
+ late MockIFavoriteRepository mockFavoriteRepository;
+ late Stream> mockStream;
+
+ setUp(() {
+ mockFavoriteRepository = MockIFavoriteRepository();
+
+ // Create mock BookDomain objects
+ final book1 = BookDomain(
+ id: '1',
+ kind: 'Fiction',
+ title: 'The Great Adventure',
+ );
+ final book2 = BookDomain(
+ id: '2',
+ kind: 'Mystery',
+ title: 'The Hidden Secret',
+ );
+
+ // Create a mock stream that emits lists of BookDomain
+ mockStream = Stream.fromIterable([
+ [book1],
+ [book1, book2],
+ ]);
+
+ // Stub the watchFavoriteBooks method to return our mock stream
+ when(
+ mockFavoriteRepository.watchFavoriteBooks(),
+ ).thenAnswer((_) => mockStream);
+ });
+
+ group('WatchFavoriteBooks - call', () {
+ test('should return the stream from favorite repository', () async {
+ // Act
+ final result = mockFavoriteRepository.watchFavoriteBooks();
+
+ // Assert
+ expect(result, equals(mockStream));
+ verify(mockFavoriteRepository.watchFavoriteBooks()).called(1);
+ verifyNoMoreInteractions(mockFavoriteRepository);
+ });
+
+ test('should emit the same values as the repository stream', () async {
+ // Arrange
+ final emittedValues = >[];
+
+ // Act
+ final subscription = mockFavoriteRepository.watchFavoriteBooks().listen(
+ emittedValues.add,
+ );
+
+ // Await for the stream to complete
+ await subscription.asFuture();
+ await subscription.cancel();
+
+ // Assert
+ expect(emittedValues.length, 2);
+ expect(emittedValues[0], [
+ BookDomain(id: '1', kind: 'Fiction', title: 'The Great Adventure'),
+ ]);
+ expect(emittedValues[1], [
+ BookDomain(id: '1', kind: 'Fiction', title: 'The Great Adventure'),
+ BookDomain(id: '2', kind: 'Mystery', title: 'The Hidden Secret'),
+ ]);
+ });
+ });
+}
diff --git a/test/layers/domain/watch_readings_test.dart b/test/layers/domain/watch_readings_test.dart
new file mode 100644
index 0000000..38a0dad
--- /dev/null
+++ b/test/layers/domain/watch_readings_test.dart
@@ -0,0 +1,116 @@
+import 'package:mibook/layers/domain/models/reading_domain.dart';
+import 'package:mibook/layers/domain/repository/reading_repository.dart';
+import 'package:mibook/layers/domain/usecases/watch_readings.dart';
+import 'package:mockito/annotations.dart';
+
+@GenerateNiceMocks([MockSpec()])
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/mockito.dart';
+import 'watch_readings_test.mocks.dart';
+
+void main() {
+ late MockIReadingRepository mockReadingRepository;
+ late WatchReadings watchReadings;
+ late Stream> mockStream;
+
+ setUp(() {
+ mockReadingRepository = MockIReadingRepository();
+ watchReadings = WatchReadings(mockReadingRepository);
+
+ // Create mock ReadingDomain objects
+ final reading1 = ReadingDomain(
+ bookId: '1',
+ bookName: 'Book 1',
+ bookThumb: 'thumb1.jpg',
+ progress: 0.5,
+ );
+ final reading2 = ReadingDomain(
+ bookId: '2',
+ bookName: 'Book 2',
+ bookThumb: 'thumb2.jpg',
+ progress: 0.8,
+ );
+
+ // Create a mock stream that emits lists of ReadingDomain
+ mockStream = Stream.fromIterable([
+ [reading1],
+ [reading1, reading2],
+ ]);
+
+ // Stub the watchReadings method to return our mock stream
+ when(mockReadingRepository.watchReadings()).thenAnswer((_) => mockStream);
+ });
+
+ group('WatchReadings - call', () {
+ test('should return the stream from reading repository', () async {
+ // Act
+ final result = watchReadings.call();
+
+ // Assert
+ expect(result, equals(mockStream));
+ verify(mockReadingRepository.watchReadings()).called(1);
+ verifyNoMoreInteractions(mockReadingRepository);
+ });
+
+ test('should emit the same values as the repository stream', () async {
+ // Arrange
+ final emittedValues = >[];
+
+ // Act
+ final subscription = watchReadings.call().listen(
+ (data) => emittedValues.add(data),
+ );
+
+ // Wait for stream to complete
+ await Future.delayed(Duration.zero);
+ await subscription.cancel();
+
+ // Assert
+ expect(emittedValues.length, equals(2));
+ expect(emittedValues[0].length, equals(1));
+ expect(emittedValues[0][0].bookId, equals('1'));
+ expect(emittedValues[1].length, equals(2));
+ expect(emittedValues[1][0].bookId, equals('1'));
+ expect(emittedValues[1][1].bookId, equals('2'));
+ verify(mockReadingRepository.watchReadings()).called(1);
+ });
+
+ test('should handle errors from repository stream', () async {
+ // Arrange
+ final errorStream = Stream>.error(
+ Exception('Repository error'),
+ );
+ when(
+ mockReadingRepository.watchReadings(),
+ ).thenAnswer((_) => errorStream);
+
+ // Act & Assert
+ expect(
+ () => watchReadings.call().first,
+ throwsA(isA()),
+ );
+ verify(mockReadingRepository.watchReadings()).called(1);
+ });
+
+ test('should handle empty stream from repository', () async {
+ // Arrange
+ final emptyStream = Stream>.empty();
+ when(
+ mockReadingRepository.watchReadings(),
+ ).thenAnswer((_) => emptyStream);
+
+ // Act
+ final emittedValues = >[];
+ final subscription = watchReadings.call().listen(
+ (data) => emittedValues.add(data),
+ );
+
+ await Future.delayed(Duration.zero);
+ await subscription.cancel();
+
+ // Assert
+ expect(emittedValues, isEmpty);
+ verify(mockReadingRepository.watchReadings()).called(1);
+ });
+ });
+}
diff --git a/test/layers/presentation/viewmodel/favorite_list_view_model_test.dart b/test/layers/presentation/viewmodel/favorite_list_view_model_test.dart
index ecabe86..7d6051b 100644
--- a/test/layers/presentation/viewmodel/favorite_list_view_model_test.dart
+++ b/test/layers/presentation/viewmodel/favorite_list_view_model_test.dart
@@ -1,53 +1,144 @@
+import 'dart:async';
+
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/domain/usecases/watch_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';
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';
+// Assuming these are the domain models - adjust if needed
+import 'package:mibook/layers/domain/models/book_list_domain.dart'; // Replace with actual path
+
@GenerateNiceMocks([
MockSpec(),
MockSpec(),
+ MockSpec(),
])
import 'favorite_list_view_model_test.mocks.dart';
void main() {
late MockIGetFavoriteList mockGetFavoriteList;
late MockISetFavorite mockSetFavorite;
+ late MockIWatchFavorite mockWatchFavorite;
late FavoriteListViewModel sut;
- setUp() {
+ setUp(() {
mockGetFavoriteList = MockIGetFavoriteList();
mockSetFavorite = MockISetFavorite();
+ mockWatchFavorite = MockIWatchFavorite();
sut = FavoriteListViewModel(
mockGetFavoriteList,
mockSetFavorite,
+ mockWatchFavorite,
);
- }
+ });
- group('test FavoriteListViewModel', () {
- setUp();
+ tearDown(() {
+ sut.close();
+ });
- test('DidAppearEvent', () async {
- when(
- mockGetFavoriteList(),
- ).thenAnswer((_) async => [fakeBookDomain]);
+ group('FavoriteListViewModel', () {
+ test('initial state is correct', () {
+ expect(sut.state, FavoriteListState.initial());
+ });
+
+ test(
+ 'DidAppearEvent starts watching favorites and emits updated state',
+ () async {
+ // Arrange
+ final favoriteDataList = [
+ BookDomain(
+ id: '1',
+ kind: 'book',
+ title: 'Book 1',
+ authors: ['Author 1'],
+ description: 'Desc 1',
+ thumbnail: 'thumb1',
+ ),
+ ];
+ final expectedUIList = favoriteDataList
+ .map((data) => FavoriteItemUI.fromDomain(data))
+ .toList();
+ when(
+ mockWatchFavorite(),
+ ).thenAnswer((_) => Stream.value(favoriteDataList));
+
+ // Act
+ sut.add(DidAppearEvent());
+
+ // Assert
+ await expectLater(
+ sut.stream,
+ emits(FavoriteListState.initial().copyWith(books: expectedUIList)),
+ );
+ verify(mockWatchFavorite()).called(1);
+ },
+ );
+
+ test('DidAppearEvent handles errors from watch favorite', () async {
+ // Arrange
+ final error = Exception('Watch error');
+ when(mockWatchFavorite()).thenAnswer((_) => Stream.error(error));
+
+ // Act
sut.add(DidAppearEvent());
+
+ // Assert
await expectLater(
sut.stream,
emits(
- predicate(
- (state) =>
- state.books.length == 1 &&
- state.books.first.id == fakeFavoriteUI.id,
+ FavoriteListState.initial().copyWith(
+ errorMessage: error.toString(),
+ isLoading: false,
),
),
);
- verify(mockGetFavoriteList()).called(1);
+ verify(mockWatchFavorite()).called(1);
+ });
+
+ test('DidTapUnfavoriteEvent unfavorites book and reloads list', () async {
+ // Arrange
+ final bookId = '1';
+ final initialBooks = [
+ FavoriteItemUI(
+ id: '1',
+ kind: 'book',
+ title: 'Book 1',
+ authors: 'Author 1',
+ description: 'Desc 1',
+ thumbnail: 'thumb1',
+ ),
+ ];
+ final updatedBooks = []; // After unfavoriting
+ sut.emit(
+ FavoriteListState.initial().copyWith(books: initialBooks),
+ ); // Set initial state
+ when(mockSetFavorite(any, false)).thenAnswer((_) async {});
+ when(
+ mockGetFavoriteList(),
+ ).thenAnswer((_) async => []); // Empty after unfavorite
+
+ // Act
+ sut.add(DidTapUnfavoriteEvent(bookId));
+
+ // Assert
+ await expectLater(
+ sut.stream,
+ emits(FavoriteListState.initial().copyWith(books: updatedBooks)),
+ );
+ verify(
+ mockSetFavorite(
+ argThat(
+ isA().having((item) => item.id, 'id', bookId),
+ ),
+ false,
+ ),
+ ).called(1);
});
});
}
diff --git a/test/layers/presentation/viewmodel/reading_list_view_model_test.dart b/test/layers/presentation/viewmodel/reading_list_view_model_test.dart
new file mode 100644
index 0000000..27738a6
--- /dev/null
+++ b/test/layers/presentation/viewmodel/reading_list_view_model_test.dart
@@ -0,0 +1,184 @@
+import 'dart:async';
+
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/mockito.dart';
+import 'package:mockito/annotations.dart';
+
+// Import your actual classes
+import 'package:mibook/layers/domain/models/reading_domain.dart';
+import 'package:mibook/layers/domain/usecases/get_readings.dart';
+import 'package:mibook/layers/domain/usecases/watch_readings.dart';
+import 'package:mibook/layers/presentation/screens/readinglist/reading_list_event.dart';
+import 'package:mibook/layers/presentation/screens/readinglist/reading_list_state.dart';
+import 'package:mibook/layers/presentation/screens/readinglist/reading_list_ui.dart';
+import 'package:mibook/layers/presentation/screens/readinglist/reading_list_view_model.dart';
+
+// Generate mocks
+@GenerateNiceMocks([MockSpec(), MockSpec()])
+import 'reading_list_view_model_test.mocks.dart';
+
+void main() {
+ late MockIGetReadings mockGetReadings;
+ late MockIWatchReadings mockWatchReadings;
+ late ReadingListViewModel readingListViewModel;
+
+ // Mock domain models
+ final reading1 = ReadingDomain(
+ bookId: '1',
+ bookName: 'Book 1',
+ bookThumb: 'thumb1.jpg',
+ progress: 0.5,
+ );
+ final reading2 = ReadingDomain(
+ bookId: '2',
+ bookName: 'Book 2',
+ bookThumb: 'thumb2.jpg',
+ progress: 0.8,
+ );
+
+ // Mock UI models
+ final uiReading1 = ReadingUI.fromDomain(reading1);
+ final uiReading2 = ReadingUI.fromDomain(reading2);
+
+ setUp(() {
+ mockGetReadings = MockIGetReadings();
+ mockWatchReadings = MockIWatchReadings();
+ readingListViewModel = ReadingListViewModel(
+ mockGetReadings,
+ mockWatchReadings,
+ );
+ });
+
+ tearDown(() {
+ readingListViewModel.close();
+ });
+
+ group('ReadingListViewModel', () {
+ group('WatchReadingListEvent', () {
+ blocTest(
+ 'emits updated state when stream emits readings',
+ build: () {
+ final mockStream = Stream.fromIterable([
+ [reading1],
+ [reading1, reading2],
+ ]);
+ when(mockWatchReadings()).thenAnswer((_) => mockStream);
+ return readingListViewModel;
+ },
+ act: (bloc) => bloc.add(WatchReadingListEvent()),
+ expect: () => [
+ ReadingListState(readings: [uiReading1]),
+ ReadingListState(readings: [uiReading1, uiReading2]),
+ ],
+ verify: (_) {
+ verify(mockWatchReadings()).called(1);
+ },
+ );
+
+ blocTest(
+ 'cancels previous subscription when called multiple times',
+ build: () {
+ final mockStream = Stream.fromIterable([
+ [reading1],
+ ]);
+ when(mockWatchReadings()).thenAnswer((_) => mockStream);
+ return readingListViewModel;
+ },
+ act: (bloc) {
+ bloc.add(WatchReadingListEvent());
+ bloc.add(WatchReadingListEvent()); // Second call
+ },
+ expect: () => [
+ ReadingListState(readings: [uiReading1]),
+ ReadingListState(readings: [uiReading1]), // Second emission
+ ],
+ verify: (_) {
+ verify(mockWatchReadings()).called(2);
+ },
+ );
+ });
+
+ group('RefreshReadingListEvent', () {
+ blocTest(
+ 'emits loading then success state when getReadings succeeds',
+ build: () {
+ when(mockGetReadings()).thenAnswer((_) async => [reading1, reading2]);
+ return readingListViewModel;
+ },
+ act: (bloc) => bloc.add(RefreshReadingListEvent()),
+ expect: () => [
+ ReadingListState(isLoading: true),
+ ReadingListState(
+ readings: [uiReading1, uiReading2],
+ isLoading: false,
+ ),
+ ],
+ verify: (_) {
+ verify(mockGetReadings()).called(1);
+ },
+ );
+
+ blocTest(
+ 'emits loading then error state when getReadings fails',
+ build: () {
+ when(mockGetReadings()).thenThrow(Exception('Network error'));
+ return readingListViewModel;
+ },
+ act: (bloc) => bloc.add(RefreshReadingListEvent()),
+ expect: () => [
+ ReadingListState(isLoading: true),
+ ReadingListState(
+ errorMessage: 'Exception: Network error',
+ isLoading: false,
+ ),
+ ],
+ verify: (_) {
+ verify(mockGetReadings()).called(1);
+ },
+ );
+ });
+
+ group('RemoveReadingItemEvent', () {
+ blocTest(
+ 'removes the specified item from readings list',
+ build: () => readingListViewModel,
+ seed: () => ReadingListState(readings: [uiReading1, uiReading2]),
+ act: (bloc) => bloc.add(RemoveReadingItemEvent('1')),
+ expect: () => [
+ ReadingListState(readings: [uiReading2]), // Only reading2 remains
+ ],
+ );
+
+ blocTest(
+ 'does nothing if item to remove is not found',
+ build: () => readingListViewModel,
+ seed: () => ReadingListState(readings: [uiReading1, uiReading2]),
+ act: (bloc) => bloc.add(RemoveReadingItemEvent('999')),
+ expect: () => [
+ ReadingListState(readings: [uiReading1, uiReading2]), // No change
+ ],
+ );
+ });
+
+ test('cancels stream subscription when closed', () async {
+ final mockStream = Stream.fromIterable([
+ [reading1],
+ ]);
+ when(mockWatchReadings()).thenAnswer((_) => mockStream);
+
+ // Add event to start subscription
+ readingListViewModel.add(WatchReadingListEvent());
+
+ // Wait for stream to start
+ await Future.delayed(Duration.zero);
+
+ // Close the bloc
+ await readingListViewModel.close();
+
+ // Verify subscription was created (we can't directly test cancellation,
+ // but we can verify the stream was called)
+ verify(mockWatchReadings()).called(1);
+ });
+ });
+}