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); + }); + }); +}