From 36bdf6613b0eca5fb8fa9968fa5d5d9ae39ae4b8 Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Mon, 10 Nov 2025 14:21:27 +0700 Subject: [PATCH] add auth --- docs/AUTH_FLOW.md | 447 ++++++++++++++++++ docs/auth.sh | 12 + docs/blog.sh | 12 + lib/app.dart | 6 +- lib/core/constants/api_constants.dart | 57 ++- lib/core/models/frappe_session_model.dart | 95 ++++ lib/core/models/frappe_session_model.g.dart | 62 +++ lib/core/network/api_interceptor.dart | 49 +- lib/core/network/api_interceptor.g.dart | 2 +- lib/core/network/dio_client.dart | 13 +- lib/core/network/dio_client.g.dart | 2 +- lib/core/router/app_router.dart | 61 +-- lib/core/services/frappe_auth_provider.dart | 28 ++ lib/core/services/frappe_auth_provider.g.dart | 67 +++ lib/core/services/frappe_auth_service.dart | 245 ++++++++++ .../datasources/auth_local_datasource.dart | 32 +- .../datasources/auth_remote_datasource.dart | 58 +++ .../auth/presentation/pages/login_page.dart | 50 +- .../presentation/providers/auth_provider.dart | 287 +++++++---- .../providers/auth_provider.g.dart | 95 +++- .../home/presentation/pages/home_page.dart | 36 +- .../datasources/news_remote_datasource.dart | 93 ++++ .../news/data/models/blog_category_model.dart | 128 +++++ .../data/models/blog_category_model.g.dart | 19 + .../repositories/news_repository_impl.dart | 26 +- .../news/domain/entities/blog_category.dart | 98 ++++ .../domain/repositories/news_repository.dart | 6 +- .../presentation/pages/news_list_page.dart | 45 +- .../presentation/providers/news_provider.dart | 48 +- .../providers/news_provider.g.dart | 160 ++++++- .../widgets/category_filter_chips.dart | 110 ++++- pubspec.lock | 8 + pubspec.yaml | 1 + 33 files changed, 2206 insertions(+), 252 deletions(-) create mode 100644 docs/AUTH_FLOW.md create mode 100644 docs/blog.sh create mode 100644 lib/core/models/frappe_session_model.dart create mode 100644 lib/core/models/frappe_session_model.g.dart create mode 100644 lib/core/services/frappe_auth_provider.dart create mode 100644 lib/core/services/frappe_auth_provider.g.dart create mode 100644 lib/core/services/frappe_auth_service.dart create mode 100644 lib/features/news/data/datasources/news_remote_datasource.dart create mode 100644 lib/features/news/data/models/blog_category_model.dart create mode 100644 lib/features/news/data/models/blog_category_model.g.dart create mode 100644 lib/features/news/domain/entities/blog_category.dart diff --git a/docs/AUTH_FLOW.md b/docs/AUTH_FLOW.md new file mode 100644 index 0000000..6f2ac57 --- /dev/null +++ b/docs/AUTH_FLOW.md @@ -0,0 +1,447 @@ +**# Authentication Flow - Frappe/ERPNext Integration + +## Overview + +The authentication system integrates with Frappe/ERPNext API using a session-based approach with SID (Session ID) and CSRF tokens stored in FlutterSecureStorage. + +## Complete Flow + +### 1. App Startup (Check Saved Session) + +**When**: User opens the app + +**Process**: +1. `Auth` provider's `build()` method is called +2. Checks if user session exists in FlutterSecureStorage +3. If logged-in session exists (userId != public_api@dbiz.com), returns User entity +4. Otherwise returns `null` (user not logged in) +5. **Note**: Public session is NOT fetched on startup to avoid provider disposal issues + +**Important**: The public session will be fetched lazily when needed: +- Before login (on login page load) +- Before registration (when loading cities/customer groups) +- Before any API call that requires session (via `ensureSession()`) + +**API Endpoint**: `POST /api/method/dbiz_common.dbiz_common.api.auth.get_session` + +**Request**: +```bash +curl -X POST 'https://land.dbiz.com/api/method/dbiz_common.dbiz_common.api.auth.get_session' \ + -H 'Content-Type: application/json' \ + -d '' +``` + +**Response**: +```json +{ + "session_expired": 1, + "message": { + "data": { + "sid": "8c39b583...", + "csrf_token": "f8a7754a9ce5..." + } + }, + "home_page": "/app", + "full_name": "Guest" +} +``` + +**Storage** (FlutterSecureStorage): +- `frappe_sid`: "8c39b583..." +- `frappe_csrf_token`: "f8a7754a9ce5..." +- `frappe_full_name`: "Guest" +- `frappe_user_id`: "public_api@dbiz.com" + +--- + +### 2. Initialize Public Session (When Needed) + +**When**: Before login or registration, or before any API call + +**Process**: +1. Call `ref.read(initializeFrappeSessionProvider.future)` on the page +2. Checks if session exists in FlutterSecureStorage +3. If no session, calls `FrappeAuthService.getSession()` +4. Stores public session (sid, csrf_token) in FlutterSecureStorage + +**Usage Example**: +```dart +// In login page or registration page initState/useEffect +@override +void initState() { + super.initState(); + // Initialize session when page loads + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(initializeFrappeSessionProvider.future); + }); +} +``` + +Or use `FutureBuilder`: +```dart +FutureBuilder( + future: ref.read(initializeFrappeSessionProvider.future), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return LoadingIndicator(); + } + return LoginForm(); // or RegistrationForm + }, +) +``` + +### 3. Loading Cities & Customer Groups (Using Public Session) + +**When**: User navigates to registration screen + +**Process**: +1. Session initialized (if not already) via `initializeFrappeSessionProvider` +2. `AuthRemoteDataSource.getCities()` is called +3. Gets stored session from FlutterSecureStorage +4. Calls API with session headers +5. Returns list of cities for address selection + +**API Endpoint**: `POST /api/method/frappe.client.get_list` + +**Request**: +```bash +curl -X POST 'https://land.dbiz.com/api/method/frappe.client.get_list' \ + -H 'Cookie: sid=8c39b583...; full_name=Guest; system_user=no; user_id=public_api%40dbiz.com; user_image=' \ + -H 'X-Frappe-CSRF-Token: f8a7754a9ce5...' \ + -H 'Content-Type: application/json' \ + -d '{ + "doctype": "City", + "fields": ["city_name", "name", "code"], + "limit_page_length": 0 + }' +``` + +**Response**: +```json +{ + "message": [ + {"city_name": "Hồ Chí Minh", "name": "HCM", "code": "HCM"}, + {"city_name": "Hà Nội", "name": "HN", "code": "HN"} + ] +} +``` + +**Similarly for Customer Groups**: +```json +{ + "doctype": "Customer Group", + "fields": ["customer_group_name", "name", "value"], + "filters": { + "is_group": 0, + "is_active": 1, + "customer": 1 + } +} +``` + +--- + +### 4. User Login (Get Authenticated Session) + +**When**: User enters phone number and password, clicks login + +**Process**: +1. `Auth.login()` is called with phone number +2. Gets current session from FlutterSecureStorage +3. Calls `AuthRemoteDataSource.login()` with phone + current session +4. API returns new authenticated session +5. `FrappeAuthService.login()` stores new session in FlutterSecureStorage +6. Dio interceptor automatically uses new session for all subsequent requests +7. Returns `User` entity with user data + +**API Endpoint**: `POST /api/method/building_material.building_material.api.auth.login` + +**Request**: +```bash +curl -X POST 'https://land.dbiz.com/api/method/building_material.building_material.api.auth.login' \ + -H 'Cookie: sid=8c39b583...' \ + -H 'X-Frappe-CSRF-Token: f8a7754a9ce5...' \ + -H 'Content-Type: application/json' \ + -d '{ + "username": "0123456789", + "googleid": null, + "facebookid": null, + "zaloid": null + }' +``` + +**Response**: +```json +{ + "session_expired": 1, + "message": { + "data": { + "sid": "new_authenticated_sid_123...", + "csrf_token": "new_csrf_token_456..." + } + }, + "home_page": "/app", + "full_name": "Nguyễn Văn A" +} +``` + +**Storage Update** (FlutterSecureStorage): +- `frappe_sid`: "new_authenticated_sid_123..." +- `frappe_csrf_token`: "new_csrf_token_456..." +- `frappe_full_name`: "Nguyễn Văn A" +- `frappe_user_id`: "0123456789" + +--- + +### 5. Authenticated API Requests + +**When**: User makes any API request after login + +**Process**: +1. `AuthInterceptor.onRequest()` is called +2. Reads session from FlutterSecureStorage +3. Builds cookie header with all required fields +4. Adds headers to request + +**Cookie Header Format**: +``` +Cookie: sid=new_authenticated_sid_123...; full_name=Nguyễn Văn A; system_user=no; user_id=0123456789; user_image= +X-Frappe-CSRF-Token: new_csrf_token_456... +``` + +**Example**: Getting products +```bash +curl -X POST 'https://land.dbiz.com/api/method/frappe.client.get_list' \ + -H 'Cookie: sid=new_authenticated_sid_123...; full_name=Nguyễn%20Văn%20A; system_user=no; user_id=0123456789; user_image=' \ + -H 'X-Frappe-CSRF-Token: new_csrf_token_456...' \ + -H 'Content-Type: application/json' \ + -d '{ + "doctype": "Item", + "fields": ["item_name", "item_code", "standard_rate"], + "limit_page_length": 20 + }' +``` + +--- + +### 6. User Logout + +**When**: User clicks logout button + +**Process**: +1. `Auth.logout()` is called +2. Clears session from both: + - `AuthLocalDataSource` (legacy Hive) + - `FrappeAuthService` (FlutterSecureStorage) +3. Gets new public session for next login/registration +4. Returns `null` (user logged out) + +**Storage Cleared**: +- `frappe_sid` +- `frappe_csrf_token` +- `frappe_full_name` +- `frappe_user_id` + +**New Public Session**: Immediately calls `getSession()` again to get fresh public session + +--- + +## File Structure + +### Core Services +- `lib/core/services/frappe_auth_service.dart` - Centralized session management +- `lib/core/models/frappe_session_model.dart` - Session response model +- `lib/core/network/api_interceptor.dart` - Dio interceptor for adding session headers + +### Auth Feature +- `lib/features/auth/data/datasources/auth_remote_datasource.dart` - API calls (login, getCities, getCustomerGroups, register) +- `lib/features/auth/data/datasources/auth_local_datasource.dart` - Legacy Hive storage +- `lib/features/auth/presentation/providers/auth_provider.dart` - State management + +### Key Components + +**FrappeAuthService**: +```dart +class FrappeAuthService { + Future getSession(); // Get public session + Future login(String phone, {String? password}); // Login + Future?> getStoredSession(); // Read from storage + Future> ensureSession(); // Ensure session exists + Future> getHeaders(); // Get headers for API calls + Future clearSession(); // Clear on logout +} +``` + +**AuthRemoteDataSource**: +```dart +class AuthRemoteDataSource { + Future getSession(); // Wrapper for Frappe getSession + Future login({phone, csrfToken, sid, password}); // Login API + Future> getCities({csrfToken, sid}); // Get cities for registration + Future> getCustomerGroups({csrfToken, sid}); // Get customer groups + Future> register({...}); // Register new user +} +``` + +**Auth Provider**: +```dart +@riverpod +class Auth extends _$Auth { + @override + Future build(); // Initialize session on app startup + + Future login({phoneNumber, password}); // Login flow + Future logout(); // Logout and get new public session +} +``` + +**AuthInterceptor**: +```dart +class AuthInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + // Read from FlutterSecureStorage + // Build cookie header + // Add to request headers + } +} +``` + +--- + +## Session Storage + +All session data is stored in **FlutterSecureStorage** (encrypted): + +| Key | Description | Example | +|-----|-------------|---------| +| `frappe_sid` | Session ID | "8c39b583..." | +| `frappe_csrf_token` | CSRF Token | "f8a7754a9ce5..." | +| `frappe_full_name` | User's full name | "Nguyễn Văn A" | +| `frappe_user_id` | User ID (phone or email) | "0123456789" or "public_api@dbiz.com" | + +--- + +## Public vs Authenticated Session + +### Public Session +- **User ID**: `public_api@dbiz.com` +- **Full Name**: "Guest" +- **Used for**: Registration, loading cities/customer groups +- **Obtained**: On app startup, after logout + +### Authenticated Session +- **User ID**: User's phone number (e.g., "0123456789") +- **Full Name**: User's actual name (e.g., "Nguyễn Văn A") +- **Used for**: All user-specific operations (orders, cart, profile) +- **Obtained**: After successful login + +--- + +## Error Handling + +All API calls use proper exception handling: + +- **401 Unauthorized**: `UnauthorizedException` - Session expired or invalid +- **404 Not Found**: `NotFoundException` - Endpoint not found +- **Network errors**: `NetworkException` - Connection failed +- **Validation errors**: `ValidationException` - Invalid data + +--- + +## Future Enhancements + +1. **Password Support**: Currently reserved but not sent. When backend supports password: + ```dart + Future login({ + required String phone, + required String csrfToken, + required String sid, + String? password, // Remove nullable, make required + }) async { + // Add 'password': password to request body + } + ``` + +2. **Token Refresh**: Implement automatic token refresh on 401 errors + +3. **Session Expiry**: Add session expiry tracking and automatic re-authentication + +4. **Biometric Login**: Store phone number and use biometric for quick re-login + +--- + +## Testing the Flow + +### 1. Test Public Session +```dart +final frappeService = ref.read(frappeAuthServiceProvider).value!; +final session = await frappeService.getSession(); +print('SID: ${session.sid}'); +print('CSRF: ${session.csrfToken}'); +``` + +### 2. Test Login +```dart +final auth = ref.read(authProvider.notifier); +await auth.login( + phoneNumber: '0123456789', + password: 'not_used_yet', +); +``` + +### 3. Test Authenticated Request +```dart +final remoteDataSource = ref.read(authRemoteDataSourceProvider).value!; +final cities = await remoteDataSource.getCities( + csrfToken: 'from_storage', + sid: 'from_storage', +); +``` + +### 4. Test Logout +```dart +await ref.read(authProvider.notifier).logout(); +``` + +--- + +## Debugging + +Enable cURL logging to see all requests: + +**In `dio_client.dart`**: +```dart +dio.interceptors.add(CurlLoggerDioInterceptor()); +``` + +**Console Output**: +``` +╔══════════════════════════════════════════════════════════════ +║ POST https://land.dbiz.com/api/method/building_material.building_material.api.auth.login +║ Headers: {Cookie: [HIDDEN], X-Frappe-CSRF-Token: [HIDDEN], ...} +║ Body: {username: 0123456789, googleid: null, ...} +╚══════════════════════════════════════════════════════════════ + +╔══════════════════════════════════════════════════════════════ +║ Response: {session_expired: 1, message: {...}, full_name: Nguyễn Văn A} +╚══════════════════════════════════════════════════════════════ +``` + +--- + +## Summary + +The authentication flow is now fully integrated with Frappe/ERPNext: + +1. ✅ App startup checks for saved user session +2. ✅ Public session fetched lazily when needed (via `initializeFrappeSessionProvider`) +3. ✅ Public session used for cities/customer groups +4. ✅ Login updates session to authenticated +5. ✅ All API requests use session from FlutterSecureStorage +6. ✅ Dio interceptor automatically adds headers +7. ✅ Logout clears session and gets new public session +8. ✅ cURL logging for debugging +9. ✅ No provider disposal errors + +All session management is centralized in `FrappeAuthService` with automatic integration via `AuthInterceptor`.** diff --git a/docs/auth.sh b/docs/auth.sh index 2fc35c6..5493b7c 100644 --- a/docs/auth.sh +++ b/docs/auth.sh @@ -55,4 +55,16 @@ curl --location 'https://land.dbiz.com//api/method/building_material.building_ma "certificates_base64" : [ "bas64_1 str","base64_2 str" ] +}' + +LOGIN +curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.auth.login' \ +--header 'Cookie: sid=18b0b29f511c1a2f4ea33a110fd9839a0da833a051a6ca30d2b387f9' \ +--header 'X-Frappe-Csrf-Token: 2b039c0e717027480d1faff125aeece598f65a2a822858e12e5c107a' \ +--header 'Content-Type: application/json' \ +--data '{ + "username" : "0978113710", + "googleid" : null, + "facebookid" : null, + "zaloid" : null }' \ No newline at end of file diff --git a/docs/blog.sh b/docs/blog.sh new file mode 100644 index 0000000..6e82528 --- /dev/null +++ b/docs/blog.sh @@ -0,0 +1,12 @@ +GET CATEGORY BLOG +curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \ +--header 'Cookie: sid=5247354a1d2a45889917a716a26cd97b19c06c1833798432c6215aac; full_name=PublicAPI; sid=5247354a1d2a45889917a716a26cd97b19c06c1833798432c6215aac; system_user=no; user_id=public_api%40dbiz.com; user_image=' \ +--header 'X-Frappe-Csrf-Token: fdd4a03b4453f49f21bc75f2c1ad3ee6ec400f750c0aea5c1f8a2ea1' \ +--header 'Content-Type: application/json' \ +--data '{ + "doctype": "Blog Category", + "fields": ["title","name"], + "filters": {"published":1}, + "order_by" : "creation desc", + "limit_page_length": 0 +}' \ No newline at end of file diff --git a/lib/app.dart b/lib/app.dart index 5f7f01a..be2a199 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -19,6 +19,9 @@ class WorkerApp extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // Watch router provider to get auth-aware router + final router = ref.watch(routerProvider); + return MaterialApp.router( // ==================== App Configuration ==================== debugShowCheckedModeBanner: false, @@ -26,7 +29,8 @@ class WorkerApp extends ConsumerWidget { // ==================== Router Configuration ==================== // Using go_router for declarative routing with deep linking support - routerConfig: AppRouter.router, + // Router is provided by Riverpod and includes auth state management + routerConfig: router, // ==================== Theme Configuration ==================== // Material 3 theme with brand colors (Primary Blue: #005B9A) diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index 2366652..2d8ffdb 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -10,23 +10,11 @@ class ApiConstants { // Base URLs // ============================================================================ - /// Base URL for development environment - static const String devBaseUrl = 'https://dev-api.worker.example.com'; + /// Base URL for all APIs (Frappe/ERPNext) + static const String baseUrl = 'https://land.dbiz.com'; - /// Base URL for staging environment - static const String stagingBaseUrl = 'https://staging-api.worker.example.com'; - - /// Base URL for production environment - static const String prodBaseUrl = 'https://api.worker.example.com'; - - /// Current base URL (should be configured based on build flavor) - static const String baseUrl = devBaseUrl; // TODO: Configure with flavors - - /// API version prefix - static const String apiVersion = '/v1'; - - /// Full API base URL with version - static String get apiBaseUrl => '$baseUrl$apiVersion'; + /// Full API base URL (no version prefix, using Frappe endpoints) + static String get apiBaseUrl => baseUrl; // ============================================================================ // Timeout Configurations @@ -347,6 +335,35 @@ class ApiConstants { /// POST /promotions/{promotionId}/claim static const String claimPromotion = '/promotions'; + // ============================================================================ + // Frappe/ERPNext API Endpoints + // ============================================================================ + + /// Frappe API method prefix + static const String frappeApiMethod = '/api/method'; + + /// Get Frappe session (public API, no auth required) + /// POST /api/method/dbiz_common.dbiz_common.api.auth.get_session + /// Returns: { "message": { "data": { "sid": "...", "csrf_token": "..." } }, "full_name": "..." } + static const String frappeGetSession = '/dbiz_common.dbiz_common.api.auth.get_session'; + + /// Login with phone (requires session sid and csrf_token) + /// POST /api/method/building_material.building_material.api.auth.login + /// Body: { "username": "phone", "googleid": null, "facebookid": null, "zaloid": null } + /// Returns: { "message": { "data": { "sid": "...", "csrf_token": "..." } }, "full_name": "..." } + static const String frappeLogin = '/building_material.building_material.api.auth.login'; + + /// Frappe client get_list (requires sid and csrf_token) + /// POST /api/method/frappe.client.get_list + static const String frappeGetList = '/frappe.client.get_list'; + + /// Register user (requires session sid and csrf_token) + /// POST /api/method/building_material.building_material.api.user.register + static const String frappeRegister = '/building_material.building_material.api.user.register'; + + /// Frappe public API user ID + static const String frappePublicUserId = 'public_api@dbiz.com'; + // ============================================================================ // Notification Endpoints // ============================================================================ @@ -380,8 +397,8 @@ class ApiConstants { /// /// Example: /// ```dart - /// final url = ApiConstants.buildUrl('/products', {'page': '1', 'limit': '20'}); - /// // Returns: https://api.worker.example.com/v1/products?page=1&limit=20 + /// final url = ApiConstants.buildUrl('/api/method/frappe.client.get_list', {'doctype': 'Item'}); + /// // Returns: https://land.dbiz.com/api/method/frappe.client.get_list?doctype=Item /// ``` static String buildUrl(String endpoint, [Map? queryParams]) { final uri = Uri.parse('$apiBaseUrl$endpoint'); @@ -395,8 +412,8 @@ class ApiConstants { /// /// Example: /// ```dart - /// final url = ApiConstants.buildUrlWithParams('/products/{id}', {'id': '123'}); - /// // Returns: https://api.worker.example.com/v1/products/123 + /// final url = ApiConstants.buildUrlWithParams('/api/resource/Item/{id}', {'id': '123'}); + /// // Returns: https://land.dbiz.com/api/resource/Item/123 /// ``` static String buildUrlWithParams( String endpoint, diff --git a/lib/core/models/frappe_session_model.dart b/lib/core/models/frappe_session_model.dart new file mode 100644 index 0000000..78d2302 --- /dev/null +++ b/lib/core/models/frappe_session_model.dart @@ -0,0 +1,95 @@ +/// Frappe Session Model +/// +/// Data model for Frappe API session response. +/// Used for public API authentication to access blog content. +library; + +import 'package:json_annotation/json_annotation.dart'; + +part 'frappe_session_model.g.dart'; + +/// Frappe Session Data +/// +/// Contains session credentials from Frappe API. +@JsonSerializable() +class FrappeSessionData { + /// Session ID + final String sid; + + /// CSRF Token + @JsonKey(name: 'csrf_token') + final String csrfToken; + + const FrappeSessionData({ + required this.sid, + required this.csrfToken, + }); + + factory FrappeSessionData.fromJson(Map json) => + _$FrappeSessionDataFromJson(json); + + Map toJson() => _$FrappeSessionDataToJson(this); +} + +/// Frappe Session Message Wrapper +@JsonSerializable() +class FrappeSessionMessage { + /// Session data + final FrappeSessionData data; + + const FrappeSessionMessage({ + required this.data, + }); + + factory FrappeSessionMessage.fromJson(Map json) => + _$FrappeSessionMessageFromJson(json); + + Map toJson() => _$FrappeSessionMessageToJson(this); +} + +/// Frappe Session Response +/// +/// API response from get_session endpoint. +/// Example: +/// ```json +/// { +/// "message": { +/// "data": { +/// "sid": "edb6059ecf147f268176cd4aff8ca034a75ebb8ff23464f9913c9537", +/// "csrf_token": "d0077178c349f69bc1456401d9a3d90ef0f7b9df3e08cfd26794a53f" +/// } +/// }, +/// "home_page": "/app", +/// "full_name": "PublicAPI" +/// } +/// ``` +@JsonSerializable() +class FrappeSessionResponse { + /// Message containing session data + final FrappeSessionMessage message; + + /// Home page path + @JsonKey(name: 'home_page') + final String homePage; + + /// Full name of the API user + @JsonKey(name: 'full_name') + final String fullName; + + const FrappeSessionResponse({ + required this.message, + required this.homePage, + required this.fullName, + }); + + factory FrappeSessionResponse.fromJson(Map json) => + _$FrappeSessionResponseFromJson(json); + + Map toJson() => _$FrappeSessionResponseToJson(this); + + /// Get session ID + String get sid => message.data.sid; + + /// Get CSRF token + String get csrfToken => message.data.csrfToken; +} diff --git a/lib/core/models/frappe_session_model.g.dart b/lib/core/models/frappe_session_model.g.dart new file mode 100644 index 0000000..a870a6b --- /dev/null +++ b/lib/core/models/frappe_session_model.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'frappe_session_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FrappeSessionData _$FrappeSessionDataFromJson(Map json) => + $checkedCreate('FrappeSessionData', json, ($checkedConvert) { + final val = FrappeSessionData( + sid: $checkedConvert('sid', (v) => v as String), + csrfToken: $checkedConvert('csrf_token', (v) => v as String), + ); + return val; + }, fieldKeyMap: const {'csrfToken': 'csrf_token'}); + +Map _$FrappeSessionDataToJson(FrappeSessionData instance) => + {'sid': instance.sid, 'csrf_token': instance.csrfToken}; + +FrappeSessionMessage _$FrappeSessionMessageFromJson( + Map json, +) => $checkedCreate('FrappeSessionMessage', json, ($checkedConvert) { + final val = FrappeSessionMessage( + data: $checkedConvert( + 'data', + (v) => FrappeSessionData.fromJson(v as Map), + ), + ); + return val; +}); + +Map _$FrappeSessionMessageToJson( + FrappeSessionMessage instance, +) => {'data': instance.data.toJson()}; + +FrappeSessionResponse _$FrappeSessionResponseFromJson( + Map json, +) => $checkedCreate( + 'FrappeSessionResponse', + json, + ($checkedConvert) { + final val = FrappeSessionResponse( + message: $checkedConvert( + 'message', + (v) => FrappeSessionMessage.fromJson(v as Map), + ), + homePage: $checkedConvert('home_page', (v) => v as String), + fullName: $checkedConvert('full_name', (v) => v as String), + ); + return val; + }, + fieldKeyMap: const {'homePage': 'home_page', 'fullName': 'full_name'}, +); + +Map _$FrappeSessionResponseToJson( + FrappeSessionResponse instance, +) => { + 'message': instance.message.toJson(), + 'home_page': instance.homePage, + 'full_name': instance.fullName, +}; diff --git a/lib/core/network/api_interceptor.dart b/lib/core/network/api_interceptor.dart index 830f32e..ddf9c64 100644 --- a/lib/core/network/api_interceptor.dart +++ b/lib/core/network/api_interceptor.dart @@ -15,7 +15,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:worker/core/constants/api_constants.dart'; import 'package:worker/core/errors/exceptions.dart'; -import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart'; part 'api_interceptor.g.dart'; @@ -35,15 +34,16 @@ class AuthStorageKeys { // Auth Interceptor // ============================================================================ -/// Interceptor for adding ERPNext session tokens to requests +/// Interceptor for adding Frappe/ERPNext session tokens to requests /// -/// Adds SID (Session ID) and CSRF token from Hive storage to request headers. +/// Adds Cookie (with SID) and X-Frappe-CSRF-Token from FlutterSecureStorage. +/// Uses the centralized FrappeAuthService for session management. class AuthInterceptor extends Interceptor { - AuthInterceptor(this._prefs, this._dio, this._authLocalDataSource); + AuthInterceptor(this._prefs, this._dio, this._secureStorage); final SharedPreferences _prefs; final Dio _dio; - final AuthLocalDataSource _authLocalDataSource; + final FlutterSecureStorage _secureStorage; @override void onRequest( @@ -52,13 +52,24 @@ class AuthInterceptor extends Interceptor { ) async { // Check if this endpoint requires authentication if (_requiresAuth(options.path)) { - // Get session data from secure storage (async) - final sid = await _authLocalDataSource.getSid(); - final csrfToken = await _authLocalDataSource.getCsrfToken(); + // Get session data from secure storage + final sid = await _secureStorage.read(key: 'frappe_sid'); + final csrfToken = await _secureStorage.read(key: 'frappe_csrf_token'); + final fullName = await _secureStorage.read(key: 'frappe_full_name'); + final userId = await _secureStorage.read(key: 'frappe_user_id'); if (sid != null && csrfToken != null) { - // Add ERPNext session headers - options.headers['Cookie'] = 'sid=$sid'; + // Build cookie header with all required fields + final cookieHeader = [ + 'sid=$sid', + 'full_name=${fullName ?? "User"}', + 'system_user=no', + 'user_id=${userId != null ? Uri.encodeComponent(userId) : ApiConstants.frappePublicUserId}', + 'user_image=', + ].join('; '); + + // Add Frappe session headers + options.headers['Cookie'] = cookieHeader; options.headers['X-Frappe-CSRF-Token'] = csrfToken; } @@ -276,9 +287,20 @@ class LoggingInterceptor extends Interceptor { name: 'HTTP Response', ); developer.log( - '║ Data: ${_truncateData(response.data, 500)}', + '║ Headers: ${response.headers.map}', name: 'HTTP Response', ); + + // Log full response data (not truncated for debugging) + final responseData = response.data; + if (responseData != null) { + if (responseData is String) { + developer.log('║ Response: $responseData', name: 'HTTP Response'); + } else { + developer.log('║ Response: ${_truncateData(responseData, 2000)}', name: 'HTTP Response'); + } + } + developer.log( '╚══════════════════════════════════════════════════════════════', name: 'HTTP Response', @@ -534,14 +556,13 @@ Future sharedPreferences(Ref ref) async { Future authInterceptor(Ref ref, Dio dio) async { final prefs = await ref.watch(sharedPreferencesProvider.future); - // Create AuthLocalDataSource with FlutterSecureStorage + // Use FlutterSecureStorage for Frappe session const secureStorage = FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), ); - final authLocalDataSource = AuthLocalDataSource(secureStorage); - return AuthInterceptor(prefs, dio, authLocalDataSource); + return AuthInterceptor(prefs, dio, secureStorage); } /// Provider for LoggingInterceptor diff --git a/lib/core/network/api_interceptor.g.dart b/lib/core/network/api_interceptor.g.dart index 17d916a..ba43112 100644 --- a/lib/core/network/api_interceptor.g.dart +++ b/lib/core/network/api_interceptor.g.dart @@ -114,7 +114,7 @@ final class AuthInterceptorProvider } } -String _$authInterceptorHash() => r'3f964536e03e204d09cc9120dd9d961b6d6d4b71'; +String _$authInterceptorHash() => r'1221aab024b7c4d9fd393f7681f3ba094286a375'; /// Provider for AuthInterceptor diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index 0d16354..536b31f 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -8,6 +8,7 @@ /// - Retry logic library; +import 'package:curl_logger_dio_interceptor/curl_logger_dio_interceptor.dart'; import 'package:dio/dio.dart'; import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; @@ -382,19 +383,21 @@ Future dio(Ref ref) async { }, ) // Add interceptors in order - // 1. Logging interceptor (first to log everything) + // 1. Curl interceptor (first to log cURL commands) + ..interceptors.add(CurlLoggerDioInterceptor()) + // 2. Logging interceptor ..interceptors.add(ref.watch(loggingInterceptorProvider)) - // 2. Auth interceptor (add tokens to requests) + // 3. Auth interceptor (add tokens to requests) ..interceptors.add(await ref.watch(authInterceptorProvider(dio).future)) - // 3. Cache interceptor + // 4. Cache interceptor ..interceptors.add( DioCacheInterceptor( options: await ref.watch(cacheOptionsProvider.future), ), ) - // 4. Retry interceptor + // 5. Retry interceptor ..interceptors.add(RetryInterceptor(ref.watch(networkInfoProvider))) - // 5. Error transformer (last to transform all errors) + // 6. Error transformer (last to transform all errors) ..interceptors.add(ref.watch(errorTransformerInterceptorProvider)); return dio; diff --git a/lib/core/network/dio_client.g.dart b/lib/core/network/dio_client.g.dart index 271c30e..6c605a3 100644 --- a/lib/core/network/dio_client.g.dart +++ b/lib/core/network/dio_client.g.dart @@ -131,7 +131,7 @@ final class DioProvider } } -String _$dioHash() => r'f15495e99d11744c245e2be892657748aeeb8ae7'; +String _$dioHash() => r'40bb4b1008c8259c9db4b19bcee674aa6732810c'; /// Provider for DioClient diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 9f405ea..985231c 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -5,9 +5,11 @@ library; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:worker/features/account/presentation/pages/addresses_page.dart'; +import 'package:worker/features/auth/presentation/providers/auth_provider.dart'; import 'package:worker/features/account/presentation/pages/change_password_page.dart'; import 'package:worker/features/account/presentation/pages/profile_edit_page.dart'; import 'package:worker/features/auth/domain/entities/business_unit.dart'; @@ -39,20 +41,41 @@ import 'package:worker/features/showrooms/presentation/pages/design_request_crea import 'package:worker/features/showrooms/presentation/pages/design_request_detail_page.dart'; import 'package:worker/features/showrooms/presentation/pages/model_houses_page.dart'; -/// App Router +/// Router Provider /// -/// Handles navigation throughout the app using declarative routing. -/// Features: -/// - Named routes for type-safe navigation -/// - Authentication guards (TODO: implement when auth is ready) -/// - Deep linking support -/// - Transition animations -class AppRouter { - /// Router configuration - static final GoRouter router = GoRouter( +/// Provides GoRouter instance with auth state management +final routerProvider = Provider((ref) { + final authState = ref.watch(authProvider); + + return GoRouter( // Initial route initialLocation: RouteNames.login, + // Redirect based on auth state + redirect: (context, state) { + final isLoggedIn = authState.value != null; + final isOnLoginPage = state.matchedLocation == RouteNames.login; + final isOnRegisterPage = state.matchedLocation == RouteNames.register; + final isOnBusinessUnitPage = + state.matchedLocation == RouteNames.businessUnitSelection; + final isOnOtpPage = state.matchedLocation == RouteNames.otpVerification; + final isOnAuthPage = + isOnLoginPage || isOnRegisterPage || isOnBusinessUnitPage || isOnOtpPage; + + // If not logged in and not on auth pages, redirect to login + if (!isLoggedIn && !isOnAuthPage) { + return RouteNames.login; + } + + // If logged in and on login page, redirect to home + if (isLoggedIn && isOnLoginPage) { + return RouteNames.home; + } + + // No redirect needed + return null; + }, + // Route definitions routes: [ // Authentication Routes @@ -384,26 +407,10 @@ class AppRouter { ), ), - // Redirect logic for authentication (TODO: implement when auth is ready) - // redirect: (context, state) { - // final isLoggedIn = false; // TODO: Get from auth provider - // final isOnLoginPage = state.matchedLocation == RouteNames.login; - // - // if (!isLoggedIn && !isOnLoginPage) { - // return RouteNames.login; - // } - // - // if (isLoggedIn && isOnLoginPage) { - // return RouteNames.home; - // } - // - // return null; - // }, - // Debug logging (disable in production) debugLogDiagnostics: true, ); -} +}); /// Route Names /// diff --git a/lib/core/services/frappe_auth_provider.dart b/lib/core/services/frappe_auth_provider.dart new file mode 100644 index 0000000..2d7feab --- /dev/null +++ b/lib/core/services/frappe_auth_provider.dart @@ -0,0 +1,28 @@ +/// Frappe Auth Provider +/// +/// Riverpod provider for FrappeAuthService. +library; + +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/core/services/frappe_auth_service.dart'; + +part 'frappe_auth_provider.g.dart'; + +/// Frappe Auth Service Provider +/// +/// Provides singleton instance of FrappeAuthService. +@riverpod +FrappeAuthService frappeAuthService(Ref ref) { + // Create a separate Dio instance for Frappe auth + // (not using the main dio client to avoid circular dependencies) + final dio = Dio(); + + const secureStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + return FrappeAuthService(dio, secureStorage); +} diff --git a/lib/core/services/frappe_auth_provider.g.dart b/lib/core/services/frappe_auth_provider.g.dart new file mode 100644 index 0000000..9acb54a --- /dev/null +++ b/lib/core/services/frappe_auth_provider.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'frappe_auth_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Frappe Auth Service Provider +/// +/// Provides singleton instance of FrappeAuthService. + +@ProviderFor(frappeAuthService) +const frappeAuthServiceProvider = FrappeAuthServiceProvider._(); + +/// Frappe Auth Service Provider +/// +/// Provides singleton instance of FrappeAuthService. + +final class FrappeAuthServiceProvider + extends + $FunctionalProvider< + FrappeAuthService, + FrappeAuthService, + FrappeAuthService + > + with $Provider { + /// Frappe Auth Service Provider + /// + /// Provides singleton instance of FrappeAuthService. + const FrappeAuthServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'frappeAuthServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$frappeAuthServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + FrappeAuthService create(Ref ref) { + return frappeAuthService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(FrappeAuthService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$frappeAuthServiceHash() => r'73112c920895302df011517e81c97eef2b5df5ac'; diff --git a/lib/core/services/frappe_auth_service.dart b/lib/core/services/frappe_auth_service.dart new file mode 100644 index 0000000..1c53d38 --- /dev/null +++ b/lib/core/services/frappe_auth_service.dart @@ -0,0 +1,245 @@ +/// Frappe Authentication Service +/// +/// Handles Frappe/ERPNext session management (sid and csrf_token). +/// Provides methods to get session, login, and manage session storage. +library; + +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:worker/core/constants/api_constants.dart'; +import 'package:worker/core/models/frappe_session_model.dart'; + +/// Frappe Auth Service +/// +/// Manages Frappe session lifecycle: +/// 1. Get initial session (public API) +/// 2. Login with phone number +/// 3. Store sid and csrf_token in secure storage +/// 4. Provide session data for API requests +class FrappeAuthService { + FrappeAuthService(this._dio, this._secureStorage); + + final Dio _dio; + final FlutterSecureStorage _secureStorage; + + /// Storage keys for Frappe session + static const String _keyFrappeSid = 'frappe_sid'; + static const String _keyFrappeCsrfToken = 'frappe_csrf_token'; + static const String _keyFrappeFullName = 'frappe_full_name'; + static const String _keyFrappeUserId = 'frappe_user_id'; + + /// Get Frappe session from API + /// + /// This endpoint doesn't require authentication - it's public. + /// Returns initial session for subsequent API calls. + /// + /// API: POST /api/method/dbiz_common.dbiz_common.api.auth.get_session + Future getSession() async { + try { + final url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetSession}'; + + final response = await _dio.post>( + url, + data: '', // Empty data as per docs + ); + + if (response.data == null) { + throw Exception('Empty response from Frappe session API'); + } + + final sessionResponse = FrappeSessionResponse.fromJson(response.data!); + + // Store session in secure storage + await _storeSession( + sid: sessionResponse.sid, + csrfToken: sessionResponse.csrfToken, + fullName: sessionResponse.fullName, + userId: ApiConstants.frappePublicUserId, + ); + + return sessionResponse; + } on DioException catch (e) { + throw Exception('Failed to get Frappe session: ${e.message}'); + } catch (e) { + throw Exception('Unexpected error getting Frappe session: $e'); + } + } + + /// Login with phone number + /// + /// Requires existing session (sid and csrf_token). + /// Returns new session with user's authentication. + /// + /// API: POST /api/method/building_material.building_material.api.auth.login + /// Headers: Cookie (with sid), X-Frappe-Csrf-Token + /// Body: { "username": "phone", "googleid": null, "facebookid": null, "zaloid": null } + /// + /// Note: Password not used yet, but field reserved for future use + Future login(String phone, {String? password}) async { + try { + // Ensure we have a session first + final session = await getStoredSession(); + if (session == null) { + await getSession(); + final newSession = await getStoredSession(); + if (newSession == null) { + throw Exception('Failed to initialize session'); + } + } + + final url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeLogin}'; + + // Build cookie header + final storedSession = await getStoredSession(); + final cookieHeader = _buildCookieHeader( + sid: storedSession!['sid']!, + fullName: storedSession['fullName']!, + userId: storedSession['userId']!, + ); + + final response = await _dio.post>( + url, + data: { + 'username': phone, + 'googleid': null, + 'facebookid': null, + 'zaloid': null, + // Password field reserved for future use + // 'password': password, + }, + options: Options( + headers: { + 'Cookie': cookieHeader, + 'X-Frappe-Csrf-Token': storedSession['csrfToken']!, + 'Content-Type': 'application/json', + }, + ), + ); + + if (response.data == null) { + throw Exception('Empty response from login API'); + } + + final loginResponse = FrappeSessionResponse.fromJson(response.data!); + + // Store new session after login + await _storeSession( + sid: loginResponse.sid, + csrfToken: loginResponse.csrfToken, + fullName: loginResponse.fullName, + userId: phone, // Use phone as userId after login + ); + + return loginResponse; + } on DioException catch (e) { + throw Exception('Login failed: ${e.message}'); + } catch (e) { + throw Exception('Unexpected error during login: $e'); + } + } + + /// Store session in secure storage + Future _storeSession({ + required String sid, + required String csrfToken, + required String fullName, + required String userId, + }) async { + await Future.wait([ + _secureStorage.write(key: _keyFrappeSid, value: sid), + _secureStorage.write(key: _keyFrappeCsrfToken, value: csrfToken), + _secureStorage.write(key: _keyFrappeFullName, value: fullName), + _secureStorage.write(key: _keyFrappeUserId, value: userId), + ]); + } + + /// Get stored session from secure storage + Future?> getStoredSession() async { + final results = await Future.wait([ + _secureStorage.read(key: _keyFrappeSid), + _secureStorage.read(key: _keyFrappeCsrfToken), + _secureStorage.read(key: _keyFrappeFullName), + _secureStorage.read(key: _keyFrappeUserId), + ]); + + final sid = results[0]; + final csrfToken = results[1]; + final fullName = results[2]; + final userId = results[3]; + + // Return null if session is incomplete + if (sid == null || csrfToken == null) { + return null; + } + + return { + 'sid': sid, + 'csrfToken': csrfToken, + 'fullName': fullName ?? 'User', + 'userId': userId ?? ApiConstants.frappePublicUserId, + }; + } + + /// Ensure valid session exists, fetch new one if needed + Future> ensureSession() async { + var session = await getStoredSession(); + + if (session == null) { + // No session in storage, get a new one + await getSession(); + session = await getStoredSession(); + if (session == null) { + throw Exception('Failed to get session'); + } + } + + return session; + } + + /// Check if session exists in storage + Future hasSession() async { + final sid = await _secureStorage.read(key: _keyFrappeSid); + final csrfToken = await _secureStorage.read(key: _keyFrappeCsrfToken); + return sid != null && csrfToken != null; + } + + /// Build cookie header string + String _buildCookieHeader({ + required String sid, + required String fullName, + required String userId, + }) { + return [ + 'sid=$sid', + 'full_name=$fullName', + 'system_user=no', + 'user_id=${Uri.encodeComponent(userId)}', + 'user_image=', + ].join('; '); + } + + /// Get headers for Frappe API requests + Future> getHeaders() async { + final session = await ensureSession(); + + return { + 'Cookie': _buildCookieHeader( + sid: session['sid']!, + fullName: session['fullName']!, + userId: session['userId']!, + ), + 'X-Frappe-Csrf-Token': session['csrfToken']!, + 'Content-Type': 'application/json', + }; + } + + /// Clear stored session + Future clearSession() async { + await Future.wait([ + _secureStorage.delete(key: _keyFrappeSid), + _secureStorage.delete(key: _keyFrappeCsrfToken), + _secureStorage.delete(key: _keyFrappeFullName), + _secureStorage.delete(key: _keyFrappeUserId), + ]); + } +} diff --git a/lib/features/auth/data/datasources/auth_local_datasource.dart b/lib/features/auth/data/datasources/auth_local_datasource.dart index 3705458..089f3b0 100644 --- a/lib/features/auth/data/datasources/auth_local_datasource.dart +++ b/lib/features/auth/data/datasources/auth_local_datasource.dart @@ -20,6 +20,7 @@ class AuthLocalDataSource { static const String _fullNameKey = 'auth_session_full_name'; static const String _createdAtKey = 'auth_session_created_at'; static const String _appsKey = 'auth_session_apps'; + static const String _rememberMeKey = 'auth_remember_me'; AuthLocalDataSource(this._secureStorage); @@ -102,21 +103,46 @@ class AuthLocalDataSource { return sid != null && csrfToken != null; } + /// Save "Remember Me" preference + /// + /// If true, user session will be restored on next app launch. + Future saveRememberMe(bool rememberMe) async { + await _secureStorage.write( + key: _rememberMeKey, + value: rememberMe.toString(), + ); + } + + /// Get "Remember Me" preference + /// + /// Returns true if user wants to be remembered, false otherwise. + Future getRememberMe() async { + final value = await _secureStorage.read(key: _rememberMeKey); + return value == 'true'; + } + /// Clear session data /// - /// Called during logout to remove all session information. + /// Called during logout to remove all session information including rememberMe. Future clearSession() async { + // Clear all session data including rememberMe await _secureStorage.delete(key: _sidKey); await _secureStorage.delete(key: _csrfTokenKey); await _secureStorage.delete(key: _fullNameKey); await _secureStorage.delete(key: _createdAtKey); await _secureStorage.delete(key: _appsKey); + await _secureStorage.delete(key: _rememberMeKey); } - /// Clear all authentication data + /// Clear all authentication data including remember me /// /// Complete cleanup of all stored auth data. Future clearAll() async { - await _secureStorage.deleteAll(); + await _secureStorage.delete(key: _sidKey); + await _secureStorage.delete(key: _csrfTokenKey); + await _secureStorage.delete(key: _fullNameKey); + await _secureStorage.delete(key: _createdAtKey); + await _secureStorage.delete(key: _appsKey); + await _secureStorage.delete(key: _rememberMeKey); } } diff --git a/lib/features/auth/data/datasources/auth_remote_datasource.dart b/lib/features/auth/data/datasources/auth_remote_datasource.dart index 54d27e9..50d0428 100644 --- a/lib/features/auth/data/datasources/auth_remote_datasource.dart +++ b/lib/features/auth/data/datasources/auth_remote_datasource.dart @@ -56,6 +56,64 @@ class AuthRemoteDataSource { } } + /// Login + /// + /// Authenticates user with phone number. + /// Requires existing session (CSRF token and Cookie). + /// Returns new session with user credentials. + /// + /// API: POST /api/method/building_material.building_material.api.auth.login + /// Body: { "username": "phone", "googleid": null, "facebookid": null, "zaloid": null } + /// + /// Response includes new sid and csrf_token for authenticated user. + Future login({ + required String phone, + required String csrfToken, + required String sid, + String? password, // Reserved for future use + }) async { + try { + final response = await _dio.post>( + '/api/method/building_material.building_material.api.auth.login', + data: { + 'username': phone, + 'googleid': null, + 'facebookid': null, + 'zaloid': null, + // Password field reserved for future use + // 'password': password, + }, + options: Options( + headers: { + 'X-Frappe-Csrf-Token': csrfToken, + 'Cookie': 'sid=$sid', + 'Content-Type': 'application/json', + }, + ), + ); + + if (response.statusCode == 200 && response.data != null) { + return GetSessionResponse.fromJson(response.data!); + } else { + throw ServerException( + 'Login failed: ${response.statusCode}', + ); + } + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + throw const UnauthorizedException('Invalid credentials'); + } else if (e.response?.statusCode == 404) { + throw NotFoundException('Login endpoint not found'); + } else { + throw NetworkException( + e.message ?? 'Failed to login', + ); + } + } catch (e) { + throw ServerException('Unexpected error during login: $e'); + } + } + /// Get Cities /// /// Fetches list of cities/provinces for address selection. diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart index ef21d05..e898569 100644 --- a/lib/features/auth/presentation/pages/login_page.dart +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -41,13 +41,16 @@ class _LoginPageState extends ConsumerState { final _formKey = GlobalKey(); // Controllers - final _phoneController = TextEditingController(text: "0988111111"); + final _phoneController = TextEditingController(text: "0978113710"); final _passwordController = TextEditingController(text: "123456"); // Focus nodes final _phoneFocusNode = FocusNode(); final _passwordFocusNode = FocusNode(); + // Remember me checkbox state + bool _rememberMe = true; + @override void dispose() { _phoneController.dispose(); @@ -74,11 +77,12 @@ class _LoginPageState extends ConsumerState { .login( phoneNumber: _phoneController.text.trim(), password: _passwordController.text, + rememberMe: _rememberMe, ); // Check if login was successful - final authState = ref.read(authProvider); - authState.when( + final authState = ref.read(authProvider) + ..when( data: (user) { if (user != null && mounted) { // Navigate to home on success @@ -402,7 +406,45 @@ class _LoginPageState extends ConsumerState { }, ), - const SizedBox(height: AppSpacing.lg), + const SizedBox(height: AppSpacing.sm), + + // Remember Me Checkbox + Row( + children: [ + Checkbox( + value: _rememberMe, + onChanged: isLoading + ? null + : (value) { + setState(() { + _rememberMe = value ?? false; + }); + }, + activeColor: AppColors.primaryBlue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4.0), + ), + ), + GestureDetector( + onTap: isLoading + ? null + : () { + setState(() { + _rememberMe = !_rememberMe; + }); + }, + child: const Text( + 'Ghi nhớ đăng nhập', + style: TextStyle( + fontSize: 14.0, + color: AppColors.grey500, + ), + ), + ), + ], + ), + + const SizedBox(height: AppSpacing.md), // Login Button SizedBox( diff --git a/lib/features/auth/presentation/providers/auth_provider.dart b/lib/features/auth/presentation/providers/auth_provider.dart index 7937910..5598cb4 100644 --- a/lib/features/auth/presentation/providers/auth_provider.dart +++ b/lib/features/auth/presentation/providers/auth_provider.dart @@ -6,9 +6,14 @@ /// Uses Riverpod 3.0 with code generation for type-safe state management. library; +import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/core/constants/api_constants.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/core/services/frappe_auth_service.dart'; import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart'; +import 'package:worker/features/auth/data/datasources/auth_remote_datasource.dart'; import 'package:worker/features/auth/data/models/auth_session_model.dart'; import 'package:worker/features/auth/domain/entities/user.dart'; @@ -30,6 +35,21 @@ AuthLocalDataSource authLocalDataSource(Ref ref) { return AuthLocalDataSource(secureStorage); } +/// Provide FrappeAuthService instance +@riverpod +Future frappeAuthService(Ref ref) async { + final dio = await ref.watch(dioProvider.future); + final secureStorage = ref.watch(secureStorageProvider); + return FrappeAuthService(dio, secureStorage); +} + +/// Provide AuthRemoteDataSource instance +@riverpod +Future authRemoteDataSource(Ref ref) async { + final dio = await ref.watch(dioProvider.future); + return AuthRemoteDataSource(dio); +} + /// Authentication state result /// /// Represents the result of authentication operations. @@ -56,19 +76,141 @@ class Auth extends _$Auth { AuthLocalDataSource get _localDataSource => ref.read(authLocalDataSourceProvider); + /// Get Frappe auth service + Future get _frappeAuthService async => + await ref.read(frappeAuthServiceProvider.future); + + /// Get auth remote data source + Future get _remoteDataSource async => + await ref.read(authRemoteDataSourceProvider.future); + /// Initialize with saved session if available @override Future build() async { - // Check for saved session in secure storage - final session = await _localDataSource.getSession(); - if (session != null) { - // User has saved session, create User entity + // Simple initialization - just check if user is logged in + // Don't call getSession() here to avoid ref disposal issues + try { + final secureStorage = ref.read(secureStorageProvider); + + // Check if "Remember Me" was enabled + final rememberMe = await _localDataSource.getRememberMe(); + + if (!rememberMe) { + // User didn't check "Remember Me", don't restore session + return null; + } + + // Check if we have a stored session + final sid = await secureStorage.read(key: 'frappe_sid'); + final userId = await secureStorage.read(key: 'frappe_user_id'); + final fullName = await secureStorage.read(key: 'frappe_full_name'); + + if (sid != null && userId != null && userId != ApiConstants.frappePublicUserId) { + // User is logged in and wants to be remembered, create User entity + final now = DateTime.now(); + return User( + userId: userId, + phoneNumber: userId, + fullName: fullName ?? 'User', + email: '', + role: UserRole.customer, + status: UserStatus.active, + loyaltyTier: LoyaltyTier.gold, + totalPoints: 0, + companyInfo: null, + cccd: null, + attachments: [], + address: null, + avatarUrl: null, + referralCode: null, + referredBy: null, + erpnextCustomerId: null, + createdAt: now.subtract(const Duration(days: 30)), + updatedAt: now, + lastLoginAt: now, + ); + } + } catch (e) { + // Failed to check session, but don't prevent app from starting + print('Failed to check saved session: $e'); + } + + return null; + } + + /// Login with phone number + /// + /// Uses Frappe ERPNext API authentication flow: + /// 1. Get current session from storage (should exist from app startup) + /// 2. Call login API with phone number + /// 3. Get new authenticated session with user credentials + /// 4. Update FlutterSecureStorage with new session + /// 5. Update Dio interceptors with new session for subsequent API calls + /// 6. Save rememberMe preference if enabled + /// + /// Parameters: + /// - [phoneNumber]: User's phone number (Vietnamese format) + /// - [password]: User's password (reserved for future use, not sent yet) + /// - [rememberMe]: If true, session will be restored on next app launch + /// + /// Returns: Authenticated User object on success + /// + /// Throws: Exception on authentication failure + Future login({ + required String phoneNumber, + required String password, + bool rememberMe = false, + }) async { + // Set loading state + state = const AsyncValue.loading(); + + state = await AsyncValue.guard(() async { + // Validation + if (phoneNumber.isEmpty) { + throw Exception('Số điện thoại không được để trống'); + } + + final frappeService = await _frappeAuthService; + final remoteDataSource = await _remoteDataSource; + + // Get current session (should exist from app startup) + final currentSession = await frappeService.getStoredSession(); + if (currentSession == null) { + // If no session, get a new one + await frappeService.getSession(); + final newSession = await frappeService.getStoredSession(); + if (newSession == null) { + throw Exception('Failed to get session'); + } + } + + // Get stored session again + final session = await frappeService.getStoredSession(); + if (session == null) { + throw Exception('Session not available'); + } + + // Call login API with current session + final loginResponse = await remoteDataSource.login( + phone: phoneNumber, + csrfToken: session['csrfToken']!, + sid: session['sid']!, + password: password, // Reserved for future use + ); + + // Update FlutterSecureStorage with new authenticated session + await frappeService.login(phoneNumber, password: password); + + // Save rememberMe preference + await _localDataSource.saveRememberMe(rememberMe); + + // Create and return User entity final now = DateTime.now(); return User( - userId: 'user_saved', // TODO: Get from API - phoneNumber: '', // TODO: Get from saved user data - fullName: session.fullName, - email: '', // TODO: Get from saved user data + userId: phoneNumber, + phoneNumber: phoneNumber, + fullName: loginResponse.fullName, + email: '', role: UserRole.customer, status: UserStatus.active, loyaltyTier: LoyaltyTier.gold, @@ -81,94 +223,6 @@ class Auth extends _$Auth { referralCode: null, referredBy: null, erpnextCustomerId: null, - createdAt: session.createdAt, - updatedAt: now, - lastLoginAt: now, - ); - } - return null; - } - - /// Login with phone number and password - /// - /// Simulates ERPNext API authentication with mock response. - /// Stores session data (SID, CSRF token) in Hive. - /// - /// Parameters: - /// - [phoneNumber]: User's phone number (Vietnamese format) - /// - [password]: User's password - /// - /// Returns: Authenticated User object on success - /// - /// Throws: Exception on authentication failure - Future login({ - required String phoneNumber, - required String password, - }) async { - // Set loading state - state = const AsyncValue.loading(); - - // Simulate API call delay - state = await AsyncValue.guard(() async { - await Future.delayed(const Duration(seconds: 2)); - - // Mock validation - if (phoneNumber.isEmpty || password.isEmpty) { - throw Exception('Số điện thoại và mật khẩu không được để trống'); - } - - if (password.length < 6) { - throw Exception('Mật khẩu phải có ít nhất 6 ký tự'); - } - - // Simulate API response matching ERPNext format - final mockApiResponse = AuthSessionResponse( - sessionExpired: 1, - message: const LoginMessage( - success: true, - message: 'Login successful', - sid: 'df7fd4e7ef1041aa3422b0ee861315ba8c28d4fe008a7d7e0e7e0e01', - csrfToken: '6b6e37563854e951c36a7af4177956bb15ca469ca4f498b742648d70', - apps: [ - AppInfo( - appTitle: 'App nhân viên kinh doanh', - appEndpoint: '/ecommerce/app-sales', - appLogo: - 'https://assets.digitalbiz.com.vn/DBIZ_Internal/Logo/logo_app_sales.png', - ), - ], - ), - homePage: '/apps', - fullName: 'Tân Duy Nguyễn', - ); - - // Save session data to Hive - final sessionData = SessionData.fromAuthResponse(mockApiResponse); - await _localDataSource.saveSession(sessionData); - - // Create and return User entity - final now = DateTime.now(); - return User( - userId: 'user_${phoneNumber.replaceAll('+84', '')}', - phoneNumber: phoneNumber, - fullName: mockApiResponse.fullName, - email: 'user@eurotile.vn', - role: UserRole.customer, - status: UserStatus.active, - loyaltyTier: LoyaltyTier.gold, - totalPoints: 1500, - companyInfo: const CompanyInfo( - name: 'Công ty TNHH XYZ', - taxId: '0123456789', - businessType: 'Xây dựng', - ), - cccd: '001234567890', - attachments: [], - address: '123 Đường ABC, Quận 1, TP.HCM', - avatarUrl: null, - referralCode: 'REF${phoneNumber.replaceAll('+84', '').substring(0, 6)}', - referredBy: null, - erpnextCustomerId: null, createdAt: now.subtract(const Duration(days: 30)), updatedAt: now, lastLoginAt: now, @@ -178,17 +232,20 @@ class Auth extends _$Auth { /// Logout current user /// - /// Clears authentication state and removes saved session from Hive. + /// Clears authentication state and removes saved session. + /// Gets a new public session for registration/login. Future logout() async { state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { - // Clear saved session from Hive + final frappeService = await _frappeAuthService; + + // Clear saved session await _localDataSource.clearSession(); + await frappeService.clearSession(); - // TODO: Call logout API to invalidate token on server - - await Future.delayed(const Duration(milliseconds: 500)); + // Get new public session for registration/login + await frappeService.getSession(); // Return null to indicate logged out return null; @@ -277,3 +334,31 @@ int userTotalPoints(Ref ref) { final user = ref.watch(currentUserProvider); return user?.totalPoints ?? 0; } + +/// Initialize Frappe session +/// +/// Call this to ensure a Frappe session exists before making API calls. +/// This is separate from the Auth provider to avoid disposal issues. +/// +/// Usage: +/// ```dart +/// // On login page or before API calls that need session +/// await ref.read(initializeFrappeSessionProvider.future); +/// ``` +@riverpod +Future initializeFrappeSession(Ref ref) async { + try { + final frappeService = await ref.watch(frappeAuthServiceProvider.future); + + // Check if we already have a session + final storedSession = await frappeService.getStoredSession(); + + if (storedSession == null) { + // No session exists, get a public one + await frappeService.getSession(); + } + } catch (e) { + // Log error but don't throw - allow app to continue + print('Failed to initialize Frappe session: $e'); + } +} diff --git a/lib/features/auth/presentation/providers/auth_provider.g.dart b/lib/features/auth/presentation/providers/auth_provider.g.dart index 8b0568a..d3a8c6b 100644 --- a/lib/features/auth/presentation/providers/auth_provider.g.dart +++ b/lib/features/auth/presentation/providers/auth_provider.g.dart @@ -113,6 +113,99 @@ final class AuthLocalDataSourceProvider String _$authLocalDataSourceHash() => r'f104de00a8ab431f6736387fb499c2b6e0ab4924'; +/// Provide FrappeAuthService instance + +@ProviderFor(frappeAuthService) +const frappeAuthServiceProvider = FrappeAuthServiceProvider._(); + +/// Provide FrappeAuthService instance + +final class FrappeAuthServiceProvider + extends + $FunctionalProvider< + AsyncValue, + FrappeAuthService, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// Provide FrappeAuthService instance + const FrappeAuthServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'frappeAuthServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$frappeAuthServiceHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return frappeAuthService(ref); + } +} + +String _$frappeAuthServiceHash() => r'db239119c9a8510d3439a2d05a7fae1743be11c5'; + +/// Provide AuthRemoteDataSource instance + +@ProviderFor(authRemoteDataSource) +const authRemoteDataSourceProvider = AuthRemoteDataSourceProvider._(); + +/// Provide AuthRemoteDataSource instance + +final class AuthRemoteDataSourceProvider + extends + $FunctionalProvider< + AsyncValue, + AuthRemoteDataSource, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// Provide AuthRemoteDataSource instance + const AuthRemoteDataSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'authRemoteDataSourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$authRemoteDataSourceHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return authRemoteDataSource(ref); + } +} + +String _$authRemoteDataSourceHash() => + r'3c05cf67fe479a973fc4ce2db68a0abde37974a5'; + /// Authentication Provider /// /// Main provider for authentication state management. @@ -179,7 +272,7 @@ final class AuthProvider extends $AsyncNotifierProvider { Auth create() => Auth(); } -String _$authHash() => r'6f410d1abe6c53a6cbfa52fde7ea7a2d22a7f78d'; +String _$authHash() => r'3f0562ffb573be47d8aae8beebccb1946240cbb6'; /// Authentication Provider /// diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index f30aee5..a36272b 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -103,24 +103,24 @@ class HomePage extends ConsumerWidget { ), // Promotions Section - SliverToBoxAdapter( - child: promotionsAsync.when( - data: (promotions) => promotions.isNotEmpty - ? PromotionSlider( - promotions: promotions, - onPromotionTap: (promotion) { - // Navigate to promotion details - context.push('/promotions/${promotion.id}'); - }, - ) - : const SizedBox.shrink(), - loading: () => const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - error: (error, stack) => const SizedBox.shrink(), - ), - ), + // SliverToBoxAdapter( + // child: promotionsAsync.when( + // data: (promotions) => promotions.isNotEmpty + // ? PromotionSlider( + // promotions: promotions, + // onPromotionTap: (promotion) { + // // Navigate to promotion details + // context.push('/promotions/${promotion.id}'); + // }, + // ) + // : const SizedBox.shrink(), + // loading: () => const Padding( + // padding: EdgeInsets.all(16), + // child: Center(child: CircularProgressIndicator()), + // ), + // error: (error, stack) => const SizedBox.shrink(), + // ), + // ), // Quick Action Sections SliverToBoxAdapter( diff --git a/lib/features/news/data/datasources/news_remote_datasource.dart b/lib/features/news/data/datasources/news_remote_datasource.dart new file mode 100644 index 0000000..bcfb1ef --- /dev/null +++ b/lib/features/news/data/datasources/news_remote_datasource.dart @@ -0,0 +1,93 @@ +/// News Remote DataSource +/// +/// Handles fetching news/blog data from the Frappe API. +library; + +import 'package:dio/dio.dart'; +import 'package:worker/core/constants/api_constants.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/core/services/frappe_auth_service.dart'; +import 'package:worker/features/news/data/models/blog_category_model.dart'; + +/// News Remote Data Source +/// +/// Provides methods to fetch news and blog content from the Frappe API. +/// Uses FrappeAuthService for session management. +class NewsRemoteDataSource { + NewsRemoteDataSource(this._dioClient, this._frappeAuthService); + + final DioClient _dioClient; + final FrappeAuthService _frappeAuthService; + + /// Get blog categories + /// + /// Fetches all published blog categories from Frappe. + /// Returns a list of [BlogCategoryModel]. + /// + /// API endpoint: POST https://land.dbiz.com/api/method/frappe.client.get_list + /// Request body: + /// ```json + /// { + /// "doctype": "Blog Category", + /// "fields": ["title","name"], + /// "filters": {"published":1}, + /// "order_by": "creation desc", + /// "limit_page_length": 0 + /// } + /// ``` + /// + /// Response format: + /// ```json + /// { + /// "message": [ + /// {"title": "Tin tức", "name": "tin-tức"}, + /// {"title": "Chuyên môn", "name": "chuyên-môn"}, + /// ... + /// ] + /// } + /// ``` + Future> getBlogCategories() async { + try { + // Get Frappe session headers + final headers = await _frappeAuthService.getHeaders(); + + // Build full API URL + final url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}'; + + final response = await _dioClient.post>( + url, + data: { + 'doctype': 'Blog Category', + 'fields': ['title', 'name'], + 'filters': {'published': 1}, + 'order_by': 'creation desc', + 'limit_page_length': 0, + }, + options: Options(headers: headers), + ); + + if (response.data == null) { + throw Exception('Empty response from server'); + } + + // Parse the response using the wrapper model + final categoriesResponse = BlogCategoriesResponse.fromJson(response.data!); + + return categoriesResponse.message; + } on DioException catch (e) { + if (e.response?.statusCode == 404) { + throw Exception('Blog categories endpoint not found'); + } else if (e.response?.statusCode == 500) { + throw Exception('Server error while fetching blog categories'); + } else if (e.type == DioExceptionType.connectionTimeout) { + throw Exception('Connection timeout while fetching blog categories'); + } else if (e.type == DioExceptionType.receiveTimeout) { + throw Exception('Response timeout while fetching blog categories'); + } else { + throw Exception('Failed to fetch blog categories: ${e.message}'); + } + } catch (e) { + throw Exception('Unexpected error fetching blog categories: $e'); + } + } +} diff --git a/lib/features/news/data/models/blog_category_model.dart b/lib/features/news/data/models/blog_category_model.dart new file mode 100644 index 0000000..4fa99a4 --- /dev/null +++ b/lib/features/news/data/models/blog_category_model.dart @@ -0,0 +1,128 @@ +/// Data Model: Blog Category +/// +/// Data Transfer Object for blog/news category information from Frappe API. +/// This model handles JSON serialization/deserialization for API responses. +library; + +import 'package:json_annotation/json_annotation.dart'; +import 'package:worker/features/news/domain/entities/blog_category.dart'; + +part 'blog_category_model.g.dart'; + +/// Blog Category Model +/// +/// Used for: +/// - API JSON serialization/deserialization +/// - Converting to/from domain entity +/// +/// Example API response: +/// ```json +/// { +/// "title": "Tin tức", +/// "name": "tin-tức" +/// } +/// ``` +@JsonSerializable() +class BlogCategoryModel { + /// Display title of the category (e.g., "Tin tức", "Chuyên môn") + final String title; + + /// URL-safe name/slug of the category (e.g., "tin-tức", "chuyên-môn") + final String name; + + const BlogCategoryModel({ + required this.title, + required this.name, + }); + + /// From JSON constructor + factory BlogCategoryModel.fromJson(Map json) => + _$BlogCategoryModelFromJson(json); + + /// To JSON method + Map toJson() => _$BlogCategoryModelToJson(this); + + /// Convert to domain entity + BlogCategory toEntity() { + return BlogCategory( + title: title, + name: name, + ); + } + + /// Create from domain entity + factory BlogCategoryModel.fromEntity(BlogCategory entity) { + return BlogCategoryModel( + title: entity.title, + name: entity.name, + ); + } + + /// Copy with method for creating modified copies + BlogCategoryModel copyWith({ + String? title, + String? name, + }) { + return BlogCategoryModel( + title: title ?? this.title, + name: name ?? this.name, + ); + } + + @override + String toString() { + return 'BlogCategoryModel(title: $title, name: $name)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is BlogCategoryModel && + other.title == title && + other.name == name; + } + + @override + int get hashCode { + return Object.hash(title, name); + } +} + +/// API Response wrapper for blog categories list +/// +/// Frappe API wraps the response in a "message" field. +/// Example: +/// ```json +/// { +/// "message": [ +/// {"title": "Tin tức", "name": "tin-tức"}, +/// {"title": "Chuyên môn", "name": "chuyên-môn"} +/// ] +/// } +/// ``` +class BlogCategoriesResponse { + /// List of blog categories + final List message; + + BlogCategoriesResponse({ + required this.message, + }); + + /// From JSON constructor + factory BlogCategoriesResponse.fromJson(Map json) { + final messageList = json['message'] as List; + final categories = messageList + .map((item) => BlogCategoryModel.fromJson(item as Map)) + .toList(); + + return BlogCategoriesResponse(message: categories); + } + + /// To JSON method + Map toJson() { + return { + 'message': message.map((category) => category.toJson()).toList(), + }; + } +} diff --git a/lib/features/news/data/models/blog_category_model.g.dart b/lib/features/news/data/models/blog_category_model.g.dart new file mode 100644 index 0000000..02d056f --- /dev/null +++ b/lib/features/news/data/models/blog_category_model.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'blog_category_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +BlogCategoryModel _$BlogCategoryModelFromJson(Map json) => + $checkedCreate('BlogCategoryModel', json, ($checkedConvert) { + final val = BlogCategoryModel( + title: $checkedConvert('title', (v) => v as String), + name: $checkedConvert('name', (v) => v as String), + ); + return val; + }); + +Map _$BlogCategoryModelToJson(BlogCategoryModel instance) => + {'title': instance.title, 'name': instance.name}; diff --git a/lib/features/news/data/repositories/news_repository_impl.dart b/lib/features/news/data/repositories/news_repository_impl.dart index b185b8e..3dd519d 100644 --- a/lib/features/news/data/repositories/news_repository_impl.dart +++ b/lib/features/news/data/repositories/news_repository_impl.dart @@ -5,6 +5,8 @@ library; import 'package:worker/features/news/data/datasources/news_local_datasource.dart'; +import 'package:worker/features/news/data/datasources/news_remote_datasource.dart'; +import 'package:worker/features/news/domain/entities/blog_category.dart'; import 'package:worker/features/news/domain/entities/news_article.dart'; import 'package:worker/features/news/domain/repositories/news_repository.dart'; @@ -13,8 +15,30 @@ class NewsRepositoryImpl implements NewsRepository { /// Local data source final NewsLocalDataSource localDataSource; + /// Remote data source + final NewsRemoteDataSource remoteDataSource; + /// Constructor - NewsRepositoryImpl({required this.localDataSource}); + NewsRepositoryImpl({ + required this.localDataSource, + required this.remoteDataSource, + }); + + @override + Future> getBlogCategories() async { + try { + // Fetch categories from remote API + final models = await remoteDataSource.getBlogCategories(); + + // Convert to domain entities + final entities = models.map((model) => model.toEntity()).toList(); + + return entities; + } catch (e) { + print('[NewsRepository] Error getting blog categories: $e'); + rethrow; // Re-throw to let providers handle the error + } + } @override Future> getAllArticles() async { diff --git a/lib/features/news/domain/entities/blog_category.dart b/lib/features/news/domain/entities/blog_category.dart new file mode 100644 index 0000000..694337c --- /dev/null +++ b/lib/features/news/domain/entities/blog_category.dart @@ -0,0 +1,98 @@ +/// Domain Entity: Blog Category +/// +/// Represents a blog/news category from the Frappe CMS. +/// This entity contains category information for filtering news articles. +/// +/// This is a pure domain entity with no external dependencies. +library; + +/// Blog Category Entity +/// +/// Contains information needed to display and filter blog categories: +/// - Display title (Vietnamese) +/// - URL-safe name/slug +/// +/// Categories from the API: +/// - Tin tức (News) +/// - Chuyên môn (Professional/Technical) +/// - Dự án (Projects) +/// - Khuyến mãi (Promotions) +class BlogCategory { + /// Display title of the category (e.g., "Tin tức", "Chuyên môn") + final String title; + + /// URL-safe name/slug of the category (e.g., "tin-tức", "chuyên-môn") + /// Used for API filtering and routing + final String name; + + /// Constructor + const BlogCategory({ + required this.title, + required this.name, + }); + + /// Get category icon name based on the category + String get iconName { + switch (name) { + case 'tin-tức': + return 'newspaper'; + case 'chuyên-môn': + return 'school'; + case 'dự-án': + return 'construction'; + case 'khuyến-mãi': + return 'local_offer'; + default: + return 'category'; + } + } + + /// Get category color based on the category + String get colorHex { + switch (name) { + case 'tin-tức': + return '#005B9A'; // Primary blue + case 'chuyên-môn': + return '#2E7D32'; // Green + case 'dự-án': + return '#F57C00'; // Orange + case 'khuyến-mãi': + return '#C62828'; // Red + default: + return '#757575'; // Grey + } + } + + /// Copy with method for immutability + BlogCategory copyWith({ + String? title, + String? name, + }) { + return BlogCategory( + title: title ?? this.title, + name: name ?? this.name, + ); + } + + /// Equality operator + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is BlogCategory && + other.title == title && + other.name == name; + } + + /// Hash code + @override + int get hashCode { + return Object.hash(title, name); + } + + /// String representation + @override + String toString() { + return 'BlogCategory(title: $title, name: $name)'; + } +} diff --git a/lib/features/news/domain/repositories/news_repository.dart b/lib/features/news/domain/repositories/news_repository.dart index 605ede0..c307dcb 100644 --- a/lib/features/news/domain/repositories/news_repository.dart +++ b/lib/features/news/domain/repositories/news_repository.dart @@ -4,12 +4,16 @@ /// This is an abstract interface following the Repository Pattern. library; +import 'package:worker/features/news/domain/entities/blog_category.dart'; import 'package:worker/features/news/domain/entities/news_article.dart'; /// News Repository Interface /// -/// Provides methods to fetch and manage news articles. +/// Provides methods to fetch and manage news articles and categories. abstract class NewsRepository { + /// Get all blog categories from Frappe API + Future> getBlogCategories(); + /// Get all news articles Future> getAllArticles(); diff --git a/lib/features/news/presentation/pages/news_list_page.dart b/lib/features/news/presentation/pages/news_list_page.dart index a36f04c..06d08ae 100644 --- a/lib/features/news/presentation/pages/news_list_page.dart +++ b/lib/features/news/presentation/pages/news_list_page.dart @@ -19,20 +19,36 @@ import 'package:worker/features/news/presentation/widgets/news_card.dart'; /// /// Features: /// - Standard AppBar with title "Tin tức & chuyên môn" -/// - Horizontal scrollable category chips (Tất cả, Tin tức, Chuyên môn, Dự án, Sự kiện, Khuyến mãi) +/// - Horizontal scrollable category chips (dynamic from Frappe API) /// - Featured article section (large card) /// - "Mới nhất" section with news cards list /// - RefreshIndicator for pull-to-refresh /// - Loading and error states -class NewsListPage extends ConsumerWidget { +class NewsListPage extends ConsumerStatefulWidget { const NewsListPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _NewsListPageState(); +} + +class _NewsListPageState extends ConsumerState { + /// Currently selected category name (null = All) + String? selectedCategoryName; + + @override + Widget build(BuildContext context) { // Watch providers final featuredArticleAsync = ref.watch(featuredArticleProvider); - final filteredArticlesAsync = ref.watch(filteredNewsArticlesProvider); - final selectedCategory = ref.watch(selectedNewsCategoryProvider); + final newsArticlesAsync = ref.watch(newsArticlesProvider); + + // Filter articles by selected category + final filteredArticles = newsArticlesAsync.whenData((articles) { + if (selectedCategoryName == null) { + return articles; + } + // TODO: Filter by category when articles have category field + return articles; + }); return Scaffold( backgroundColor: Colors.white, @@ -40,9 +56,10 @@ class NewsListPage extends ConsumerWidget { body: RefreshIndicator( onRefresh: () async { // Invalidate providers to trigger refresh - ref.invalidate(newsArticlesProvider); - ref.invalidate(featuredArticleProvider); - ref.invalidate(filteredNewsArticlesProvider); + ref + ..invalidate(newsArticlesProvider) + ..invalidate(featuredArticleProvider) + ..invalidate(blogCategoriesProvider); }, child: CustomScrollView( slivers: [ @@ -51,11 +68,11 @@ class NewsListPage extends ConsumerWidget { child: Padding( padding: const EdgeInsets.only(top: 4, bottom: AppSpacing.md), child: CategoryFilterChips( - selectedCategory: selectedCategory, - onCategorySelected: (category) { - ref - .read(selectedNewsCategoryProvider.notifier) - .setCategory(category); + selectedCategoryName: selectedCategoryName, + onCategorySelected: (categoryName) { + setState(() { + selectedCategoryName = categoryName; + }); }, ), ), @@ -148,7 +165,7 @@ class NewsListPage extends ConsumerWidget { const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)), // News List - filteredArticlesAsync.when( + filteredArticles.when( data: (articles) { if (articles.isEmpty) { return SliverFillRemaining(child: _buildEmptyState()); diff --git a/lib/features/news/presentation/providers/news_provider.dart b/lib/features/news/presentation/providers/news_provider.dart index cf2247c..33ff557 100644 --- a/lib/features/news/presentation/providers/news_provider.dart +++ b/lib/features/news/presentation/providers/news_provider.dart @@ -5,10 +5,14 @@ library; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/core/services/frappe_auth_provider.dart'; import 'package:worker/features/news/data/datasources/news_local_datasource.dart'; -import 'package:worker/features/news/data/repositories/news_repository_impl.dart'; +import 'package:worker/features/news/data/datasources/news_remote_datasource.dart'; +import 'package:worker/features/news/domain/entities/blog_category.dart'; import 'package:worker/features/news/domain/entities/news_article.dart'; import 'package:worker/features/news/domain/repositories/news_repository.dart'; +import 'package:worker/features/news/data/repositories/news_repository_impl.dart'; part 'news_provider.g.dart'; @@ -20,13 +24,27 @@ NewsLocalDataSource newsLocalDataSource(Ref ref) { return NewsLocalDataSource(); } +/// News Remote DataSource Provider +/// +/// Provides instance of NewsRemoteDataSource with Frappe auth service. +@riverpod +Future newsRemoteDataSource(Ref ref) async { + final dioClient = await ref.watch(dioClientProvider.future); + final frappeAuthService = ref.watch(frappeAuthServiceProvider); + return NewsRemoteDataSource(dioClient, frappeAuthService); +} + /// News Repository Provider /// /// Provides instance of NewsRepository implementation. @riverpod -NewsRepository newsRepository(Ref ref) { +Future newsRepository(Ref ref) async { final localDataSource = ref.watch(newsLocalDataSourceProvider); - return NewsRepositoryImpl(localDataSource: localDataSource); + final remoteDataSource = await ref.watch(newsRemoteDataSourceProvider.future); + return NewsRepositoryImpl( + localDataSource: localDataSource, + remoteDataSource: remoteDataSource, + ); } /// News Articles Provider @@ -35,7 +53,7 @@ NewsRepository newsRepository(Ref ref) { /// Returns AsyncValue> for proper loading/error handling. @riverpod Future> newsArticles(Ref ref) async { - final repository = ref.watch(newsRepositoryProvider); + final repository = await ref.watch(newsRepositoryProvider.future); return repository.getAllArticles(); } @@ -45,7 +63,7 @@ Future> newsArticles(Ref ref) async { /// Returns AsyncValue (null if no featured article). @riverpod Future featuredArticle(Ref ref) async { - final repository = ref.watch(newsRepositoryProvider); + final repository = await ref.watch(newsRepositoryProvider.future); return repository.getFeaturedArticle(); } @@ -79,7 +97,7 @@ class SelectedNewsCategory extends _$SelectedNewsCategory { @riverpod Future> filteredNewsArticles(Ref ref) async { final selectedCategory = ref.watch(selectedNewsCategoryProvider); - final repository = ref.watch(newsRepositoryProvider); + final repository = await ref.watch(newsRepositoryProvider.future); // If no category selected, return all articles if (selectedCategory == null) { @@ -96,6 +114,22 @@ Future> filteredNewsArticles(Ref ref) async { /// Used for article detail page. @riverpod Future newsArticleById(Ref ref, String articleId) async { - final repository = ref.watch(newsRepositoryProvider); + final repository = await ref.watch(newsRepositoryProvider.future); return repository.getArticleById(articleId); } + +/// Blog Categories Provider +/// +/// Fetches all published blog categories from Frappe API. +/// Returns AsyncValue> (domain entities) for proper loading/error handling. +/// +/// Example categories: +/// - Tin tức (News) +/// - Chuyên môn (Professional) +/// - Dự án (Projects) +/// - Khuyến mãi (Promotions) +@riverpod +Future> blogCategories(Ref ref) async { + final repository = await ref.watch(newsRepositoryProvider.future); + return repository.getBlogCategories(); +} diff --git a/lib/features/news/presentation/providers/news_provider.g.dart b/lib/features/news/presentation/providers/news_provider.g.dart index 95b1eba..7becf87 100644 --- a/lib/features/news/presentation/providers/news_provider.g.dart +++ b/lib/features/news/presentation/providers/news_provider.g.dart @@ -67,6 +67,59 @@ final class NewsLocalDataSourceProvider String _$newsLocalDataSourceHash() => r'e7e7d71d20274fe8b498c7b15f8aeb9eb515af27'; +/// News Remote DataSource Provider +/// +/// Provides instance of NewsRemoteDataSource with Frappe auth service. + +@ProviderFor(newsRemoteDataSource) +const newsRemoteDataSourceProvider = NewsRemoteDataSourceProvider._(); + +/// News Remote DataSource Provider +/// +/// Provides instance of NewsRemoteDataSource with Frappe auth service. + +final class NewsRemoteDataSourceProvider + extends + $FunctionalProvider< + AsyncValue, + NewsRemoteDataSource, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// News Remote DataSource Provider + /// + /// Provides instance of NewsRemoteDataSource with Frappe auth service. + const NewsRemoteDataSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'newsRemoteDataSourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$newsRemoteDataSourceHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return newsRemoteDataSource(ref); + } +} + +String _$newsRemoteDataSourceHash() => + r'27db8dc4fadf806349fe4f0ad5fed1999620c1a3'; + /// News Repository Provider /// /// Provides instance of NewsRepository implementation. @@ -79,8 +132,13 @@ const newsRepositoryProvider = NewsRepositoryProvider._(); /// Provides instance of NewsRepository implementation. final class NewsRepositoryProvider - extends $FunctionalProvider - with $Provider { + extends + $FunctionalProvider< + AsyncValue, + NewsRepository, + FutureOr + > + with $FutureModifier, $FutureProvider { /// News Repository Provider /// /// Provides instance of NewsRepository implementation. @@ -100,24 +158,17 @@ final class NewsRepositoryProvider @$internal @override - $ProviderElement $createElement($ProviderPointer pointer) => - $ProviderElement(pointer); + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); @override - NewsRepository create(Ref ref) { + FutureOr create(Ref ref) { return newsRepository(ref); } - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(NewsRepository value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider(value), - ); - } } -String _$newsRepositoryHash() => r'1536188fae6934f147f022a8f5d7bd62ff9453b5'; +String _$newsRepositoryHash() => r'8e66d847014926ad542e402874e52d35b00cdbcc'; /// News Articles Provider /// @@ -172,7 +223,7 @@ final class NewsArticlesProvider } } -String _$newsArticlesHash() => r'24d70e49f7137c614c024dc93c97451c6e161ce6'; +String _$newsArticlesHash() => r'789d916f1ce7d76f26429cfce97c65a71915edf3'; /// Featured Article Provider /// @@ -225,7 +276,7 @@ final class FeaturedArticleProvider } } -String _$featuredArticleHash() => r'f7146600bc3bbaf5987ab6b09262135b1558f1c0'; +String _$featuredArticleHash() => r'5fd7057d3f828d6f717b08d59561aa9637eb0097'; /// Selected News Category Provider /// @@ -353,7 +404,7 @@ final class FilteredNewsArticlesProvider } String _$filteredNewsArticlesHash() => - r'f40a737b74b44f2d4fa86977175314ed0da471fa'; + r'f5d6faa2d510eae188f12fa41d052eeb43e08cc9'; /// News Article by ID Provider /// @@ -424,7 +475,7 @@ final class NewsArticleByIdProvider } } -String _$newsArticleByIdHash() => r'4d28caa81d486fcd6cfefd16477355927bbcadc8'; +String _$newsArticleByIdHash() => r'f2b5ee4a3f7b67d0ee9e9c91169d740a9f250b50'; /// News Article by ID Provider /// @@ -453,3 +504,76 @@ final class NewsArticleByIdFamily extends $Family @override String toString() => r'newsArticleByIdProvider'; } + +/// Blog Categories Provider +/// +/// Fetches all published blog categories from Frappe API. +/// Returns AsyncValue> (domain entities) for proper loading/error handling. +/// +/// Example categories: +/// - Tin tức (News) +/// - Chuyên môn (Professional) +/// - Dự án (Projects) +/// - Khuyến mãi (Promotions) + +@ProviderFor(blogCategories) +const blogCategoriesProvider = BlogCategoriesProvider._(); + +/// Blog Categories Provider +/// +/// Fetches all published blog categories from Frappe API. +/// Returns AsyncValue> (domain entities) for proper loading/error handling. +/// +/// Example categories: +/// - Tin tức (News) +/// - Chuyên môn (Professional) +/// - Dự án (Projects) +/// - Khuyến mãi (Promotions) + +final class BlogCategoriesProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// Blog Categories Provider + /// + /// Fetches all published blog categories from Frappe API. + /// Returns AsyncValue> (domain entities) for proper loading/error handling. + /// + /// Example categories: + /// - Tin tức (News) + /// - Chuyên môn (Professional) + /// - Dự án (Projects) + /// - Khuyến mãi (Promotions) + const BlogCategoriesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'blogCategoriesProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$blogCategoriesHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return blogCategories(ref); + } +} + +String _$blogCategoriesHash() => r'd87493142946be20ab309ea94d6173a8005b516e'; diff --git a/lib/features/news/presentation/widgets/category_filter_chips.dart b/lib/features/news/presentation/widgets/category_filter_chips.dart index bd6c00b..ca52073 100644 --- a/lib/features/news/presentation/widgets/category_filter_chips.dart +++ b/lib/features/news/presentation/widgets/category_filter_chips.dart @@ -2,37 +2,53 @@ /// /// Horizontal scrollable list of category filter chips. /// Used in news list page for filtering articles by category. +/// Fetches categories dynamically from the Frappe API. library; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/theme/colors.dart'; -import 'package:worker/features/news/domain/entities/news_article.dart'; +import 'package:worker/features/news/domain/entities/blog_category.dart'; +import 'package:worker/features/news/presentation/providers/news_provider.dart'; /// Category Filter Chips /// /// Displays a horizontal scrollable row of filter chips for news categories. /// Features: /// - "Tất cả" (All) option to show all categories -/// - 5 category options: Tin tức, Chuyên môn, Dự án, Sự kiện, Khuyến mãi +/// - Dynamic categories from Frappe API (Tin tức, Chuyên môn, Dự án, Khuyến mãi) /// - Active state styling (primary blue background, white text) /// - Inactive state styling (grey background, grey text) -class CategoryFilterChips extends StatelessWidget { - /// Currently selected category (null = All) - final NewsCategory? selectedCategory; +/// - Loading state with shimmer effect +/// - Error state with retry button +class CategoryFilterChips extends ConsumerWidget { + /// Currently selected category name (null = All) + final String? selectedCategoryName; - /// Callback when a category is tapped - final void Function(NewsCategory? category) onCategorySelected; + /// Callback when a category is tapped (passes category name) + final void Function(String? categoryName) onCategorySelected; /// Constructor const CategoryFilterChips({ super.key, - required this.selectedCategory, + required this.selectedCategoryName, required this.onCategorySelected, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final categoriesAsync = ref.watch(blogCategoriesProvider); + + return categoriesAsync.when( + data: (categories) => _buildCategoryChips(categories), + loading: () => _buildLoadingState(), + error: (error, stack) => _buildErrorState(error, ref), + ); + } + + /// Build category chips with data + Widget _buildCategoryChips(List categories) { return SingleChildScrollView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), @@ -41,20 +57,20 @@ class CategoryFilterChips extends StatelessWidget { // "Tất cả" chip _buildCategoryChip( label: 'Tất cả', - isSelected: selectedCategory == null, + isSelected: selectedCategoryName == null, onTap: () => onCategorySelected(null), ), const SizedBox(width: AppSpacing.sm), - // Category chips - ...NewsCategory.values.map((category) { + // Dynamic category chips from API + ...categories.map((category) { return Padding( padding: const EdgeInsets.only(right: AppSpacing.sm), child: _buildCategoryChip( - label: category.displayName, - isSelected: selectedCategory == category, - onTap: () => onCategorySelected(category), + label: category.title, + isSelected: selectedCategoryName == category.name, + onTap: () => onCategorySelected(category.name), ), ); }), @@ -63,6 +79,70 @@ class CategoryFilterChips extends StatelessWidget { ); } + /// Build loading state with shimmer placeholders + Widget _buildLoadingState() { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Row( + children: List.generate(5, (index) { + return Padding( + padding: const EdgeInsets.only(right: AppSpacing.sm), + child: Container( + width: 80, + height: 32, + decoration: BoxDecoration( + color: AppColors.grey100, + borderRadius: BorderRadius.circular(24), + ), + ), + ); + }), + ), + ); + } + + /// Build error state with retry + Widget _buildErrorState(Object error, WidgetRef ref) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.grey100, + borderRadius: BorderRadius.circular(24), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 16, color: AppColors.grey500), + const SizedBox(width: AppSpacing.xs), + Text( + 'Lỗi tải danh mục', + style: TextStyle( + fontSize: 14, + color: AppColors.grey500, + ), + ), + const SizedBox(width: AppSpacing.xs), + GestureDetector( + onTap: () => ref.refresh(blogCategoriesProvider), + child: Icon(Icons.refresh, size: 16, color: AppColors.primaryBlue), + ), + ], + ), + ), + ], + ), + ); + } + /// Build individual category chip Widget _buildCategoryChip({ required String label, diff --git a/pubspec.lock b/pubspec.lock index 6beadff..a1f62a4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -353,6 +353,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" + dio_intercept_to_curl: + dependency: "direct main" + description: + name: dio_intercept_to_curl + sha256: aaf22b05858385ee00dfabd8a1c74265f2e9057a266047a75d81c468f1fcf854 + url: "https://pub.dev" + source: hosted + version: "0.2.0" dio_web_adapter: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cc75320..2aa004d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: connectivity_plus: ^6.0.3 pretty_dio_logger: ^1.3.1 curl_logger_dio_interceptor: ^1.0.0 + dio_intercept_to_curl: ^0.2.0 dio_cache_interceptor: ^3.5.0 dio_cache_interceptor_hive_store: ^3.2.2