add auth
This commit is contained in:
447
docs/AUTH_FLOW.md
Normal file
447
docs/AUTH_FLOW.md
Normal file
@@ -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<FrappeSessionResponse> getSession(); // Get public session
|
||||||
|
Future<FrappeSessionResponse> login(String phone, {String? password}); // Login
|
||||||
|
Future<Map<String, String>?> getStoredSession(); // Read from storage
|
||||||
|
Future<Map<String, String>> ensureSession(); // Ensure session exists
|
||||||
|
Future<Map<String, String>> getHeaders(); // Get headers for API calls
|
||||||
|
Future<void> clearSession(); // Clear on logout
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AuthRemoteDataSource**:
|
||||||
|
```dart
|
||||||
|
class AuthRemoteDataSource {
|
||||||
|
Future<GetSessionResponse> getSession(); // Wrapper for Frappe getSession
|
||||||
|
Future<GetSessionResponse> login({phone, csrfToken, sid, password}); // Login API
|
||||||
|
Future<List<City>> getCities({csrfToken, sid}); // Get cities for registration
|
||||||
|
Future<List<CustomerGroup>> getCustomerGroups({csrfToken, sid}); // Get customer groups
|
||||||
|
Future<Map<String, dynamic>> register({...}); // Register new user
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth Provider**:
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class Auth extends _$Auth {
|
||||||
|
@override
|
||||||
|
Future<User?> build(); // Initialize session on app startup
|
||||||
|
|
||||||
|
Future<void> login({phoneNumber, password}); // Login flow
|
||||||
|
Future<void> 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<GetSessionResponse> 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`.**
|
||||||
12
docs/auth.sh
12
docs/auth.sh
@@ -56,3 +56,15 @@ curl --location 'https://land.dbiz.com//api/method/building_material.building_ma
|
|||||||
"bas64_1 str","base64_2 str"
|
"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
|
||||||
|
}'
|
||||||
12
docs/blog.sh
Normal file
12
docs/blog.sh
Normal file
@@ -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
|
||||||
|
}'
|
||||||
@@ -19,6 +19,9 @@ class WorkerApp extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Watch router provider to get auth-aware router
|
||||||
|
final router = ref.watch(routerProvider);
|
||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
// ==================== App Configuration ====================
|
// ==================== App Configuration ====================
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
@@ -26,7 +29,8 @@ class WorkerApp extends ConsumerWidget {
|
|||||||
|
|
||||||
// ==================== Router Configuration ====================
|
// ==================== Router Configuration ====================
|
||||||
// Using go_router for declarative routing with deep linking support
|
// 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 ====================
|
// ==================== Theme Configuration ====================
|
||||||
// Material 3 theme with brand colors (Primary Blue: #005B9A)
|
// Material 3 theme with brand colors (Primary Blue: #005B9A)
|
||||||
|
|||||||
@@ -10,23 +10,11 @@ class ApiConstants {
|
|||||||
// Base URLs
|
// Base URLs
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Base URL for development environment
|
/// Base URL for all APIs (Frappe/ERPNext)
|
||||||
static const String devBaseUrl = 'https://dev-api.worker.example.com';
|
static const String baseUrl = 'https://land.dbiz.com';
|
||||||
|
|
||||||
/// Base URL for staging environment
|
/// Full API base URL (no version prefix, using Frappe endpoints)
|
||||||
static const String stagingBaseUrl = 'https://staging-api.worker.example.com';
|
static String get apiBaseUrl => baseUrl;
|
||||||
|
|
||||||
/// 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';
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Timeout Configurations
|
// Timeout Configurations
|
||||||
@@ -347,6 +335,35 @@ class ApiConstants {
|
|||||||
/// POST /promotions/{promotionId}/claim
|
/// POST /promotions/{promotionId}/claim
|
||||||
static const String claimPromotion = '/promotions';
|
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
|
// Notification Endpoints
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -380,8 +397,8 @@ class ApiConstants {
|
|||||||
///
|
///
|
||||||
/// Example:
|
/// Example:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// final url = ApiConstants.buildUrl('/products', {'page': '1', 'limit': '20'});
|
/// final url = ApiConstants.buildUrl('/api/method/frappe.client.get_list', {'doctype': 'Item'});
|
||||||
/// // Returns: https://api.worker.example.com/v1/products?page=1&limit=20
|
/// // Returns: https://land.dbiz.com/api/method/frappe.client.get_list?doctype=Item
|
||||||
/// ```
|
/// ```
|
||||||
static String buildUrl(String endpoint, [Map<String, String>? queryParams]) {
|
static String buildUrl(String endpoint, [Map<String, String>? queryParams]) {
|
||||||
final uri = Uri.parse('$apiBaseUrl$endpoint');
|
final uri = Uri.parse('$apiBaseUrl$endpoint');
|
||||||
@@ -395,8 +412,8 @@ class ApiConstants {
|
|||||||
///
|
///
|
||||||
/// Example:
|
/// Example:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// final url = ApiConstants.buildUrlWithParams('/products/{id}', {'id': '123'});
|
/// final url = ApiConstants.buildUrlWithParams('/api/resource/Item/{id}', {'id': '123'});
|
||||||
/// // Returns: https://api.worker.example.com/v1/products/123
|
/// // Returns: https://land.dbiz.com/api/resource/Item/123
|
||||||
/// ```
|
/// ```
|
||||||
static String buildUrlWithParams(
|
static String buildUrlWithParams(
|
||||||
String endpoint,
|
String endpoint,
|
||||||
|
|||||||
95
lib/core/models/frappe_session_model.dart
Normal file
95
lib/core/models/frappe_session_model.dart
Normal file
@@ -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<String, dynamic> json) =>
|
||||||
|
_$FrappeSessionDataFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$FrappeSessionDataToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frappe Session Message Wrapper
|
||||||
|
@JsonSerializable()
|
||||||
|
class FrappeSessionMessage {
|
||||||
|
/// Session data
|
||||||
|
final FrappeSessionData data;
|
||||||
|
|
||||||
|
const FrappeSessionMessage({
|
||||||
|
required this.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FrappeSessionMessage.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$FrappeSessionMessageFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) =>
|
||||||
|
_$FrappeSessionResponseFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$FrappeSessionResponseToJson(this);
|
||||||
|
|
||||||
|
/// Get session ID
|
||||||
|
String get sid => message.data.sid;
|
||||||
|
|
||||||
|
/// Get CSRF token
|
||||||
|
String get csrfToken => message.data.csrfToken;
|
||||||
|
}
|
||||||
62
lib/core/models/frappe_session_model.g.dart
Normal file
62
lib/core/models/frappe_session_model.g.dart
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'frappe_session_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
FrappeSessionData _$FrappeSessionDataFromJson(Map<String, dynamic> 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<String, dynamic> _$FrappeSessionDataToJson(FrappeSessionData instance) =>
|
||||||
|
<String, dynamic>{'sid': instance.sid, 'csrf_token': instance.csrfToken};
|
||||||
|
|
||||||
|
FrappeSessionMessage _$FrappeSessionMessageFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => $checkedCreate('FrappeSessionMessage', json, ($checkedConvert) {
|
||||||
|
final val = FrappeSessionMessage(
|
||||||
|
data: $checkedConvert(
|
||||||
|
'data',
|
||||||
|
(v) => FrappeSessionData.fromJson(v as Map<String, dynamic>),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> _$FrappeSessionMessageToJson(
|
||||||
|
FrappeSessionMessage instance,
|
||||||
|
) => <String, dynamic>{'data': instance.data.toJson()};
|
||||||
|
|
||||||
|
FrappeSessionResponse _$FrappeSessionResponseFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => $checkedCreate(
|
||||||
|
'FrappeSessionResponse',
|
||||||
|
json,
|
||||||
|
($checkedConvert) {
|
||||||
|
final val = FrappeSessionResponse(
|
||||||
|
message: $checkedConvert(
|
||||||
|
'message',
|
||||||
|
(v) => FrappeSessionMessage.fromJson(v as Map<String, dynamic>),
|
||||||
|
),
|
||||||
|
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<String, dynamic> _$FrappeSessionResponseToJson(
|
||||||
|
FrappeSessionResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'message': instance.message.toJson(),
|
||||||
|
'home_page': instance.homePage,
|
||||||
|
'full_name': instance.fullName,
|
||||||
|
};
|
||||||
@@ -15,7 +15,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:worker/core/constants/api_constants.dart';
|
import 'package:worker/core/constants/api_constants.dart';
|
||||||
import 'package:worker/core/errors/exceptions.dart';
|
import 'package:worker/core/errors/exceptions.dart';
|
||||||
import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart';
|
|
||||||
|
|
||||||
part 'api_interceptor.g.dart';
|
part 'api_interceptor.g.dart';
|
||||||
|
|
||||||
@@ -35,15 +34,16 @@ class AuthStorageKeys {
|
|||||||
// Auth Interceptor
|
// 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 {
|
class AuthInterceptor extends Interceptor {
|
||||||
AuthInterceptor(this._prefs, this._dio, this._authLocalDataSource);
|
AuthInterceptor(this._prefs, this._dio, this._secureStorage);
|
||||||
|
|
||||||
final SharedPreferences _prefs;
|
final SharedPreferences _prefs;
|
||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
final AuthLocalDataSource _authLocalDataSource;
|
final FlutterSecureStorage _secureStorage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onRequest(
|
void onRequest(
|
||||||
@@ -52,13 +52,24 @@ class AuthInterceptor extends Interceptor {
|
|||||||
) async {
|
) async {
|
||||||
// Check if this endpoint requires authentication
|
// Check if this endpoint requires authentication
|
||||||
if (_requiresAuth(options.path)) {
|
if (_requiresAuth(options.path)) {
|
||||||
// Get session data from secure storage (async)
|
// Get session data from secure storage
|
||||||
final sid = await _authLocalDataSource.getSid();
|
final sid = await _secureStorage.read(key: 'frappe_sid');
|
||||||
final csrfToken = await _authLocalDataSource.getCsrfToken();
|
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) {
|
if (sid != null && csrfToken != null) {
|
||||||
// Add ERPNext session headers
|
// Build cookie header with all required fields
|
||||||
options.headers['Cookie'] = 'sid=$sid';
|
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;
|
options.headers['X-Frappe-CSRF-Token'] = csrfToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,9 +287,20 @@ class LoggingInterceptor extends Interceptor {
|
|||||||
name: 'HTTP Response',
|
name: 'HTTP Response',
|
||||||
);
|
);
|
||||||
developer.log(
|
developer.log(
|
||||||
'║ Data: ${_truncateData(response.data, 500)}',
|
'║ Headers: ${response.headers.map}',
|
||||||
name: 'HTTP Response',
|
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(
|
developer.log(
|
||||||
'╚══════════════════════════════════════════════════════════════',
|
'╚══════════════════════════════════════════════════════════════',
|
||||||
name: 'HTTP Response',
|
name: 'HTTP Response',
|
||||||
@@ -534,14 +556,13 @@ Future<SharedPreferences> sharedPreferences(Ref ref) async {
|
|||||||
Future<AuthInterceptor> authInterceptor(Ref ref, Dio dio) async {
|
Future<AuthInterceptor> authInterceptor(Ref ref, Dio dio) async {
|
||||||
final prefs = await ref.watch(sharedPreferencesProvider.future);
|
final prefs = await ref.watch(sharedPreferencesProvider.future);
|
||||||
|
|
||||||
// Create AuthLocalDataSource with FlutterSecureStorage
|
// Use FlutterSecureStorage for Frappe session
|
||||||
const secureStorage = FlutterSecureStorage(
|
const secureStorage = FlutterSecureStorage(
|
||||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||||
);
|
);
|
||||||
final authLocalDataSource = AuthLocalDataSource(secureStorage);
|
|
||||||
|
|
||||||
return AuthInterceptor(prefs, dio, authLocalDataSource);
|
return AuthInterceptor(prefs, dio, secureStorage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider for LoggingInterceptor
|
/// Provider for LoggingInterceptor
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ final class AuthInterceptorProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$authInterceptorHash() => r'3f964536e03e204d09cc9120dd9d961b6d6d4b71';
|
String _$authInterceptorHash() => r'1221aab024b7c4d9fd393f7681f3ba094286a375';
|
||||||
|
|
||||||
/// Provider for AuthInterceptor
|
/// Provider for AuthInterceptor
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
/// - Retry logic
|
/// - Retry logic
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'package:curl_logger_dio_interceptor/curl_logger_dio_interceptor.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
|
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
|
||||||
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
||||||
@@ -382,19 +383,21 @@ Future<Dio> dio(Ref ref) async {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
// Add interceptors in order
|
// 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))
|
..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))
|
..interceptors.add(await ref.watch(authInterceptorProvider(dio).future))
|
||||||
// 3. Cache interceptor
|
// 4. Cache interceptor
|
||||||
..interceptors.add(
|
..interceptors.add(
|
||||||
DioCacheInterceptor(
|
DioCacheInterceptor(
|
||||||
options: await ref.watch(cacheOptionsProvider.future),
|
options: await ref.watch(cacheOptionsProvider.future),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// 4. Retry interceptor
|
// 5. Retry interceptor
|
||||||
..interceptors.add(RetryInterceptor(ref.watch(networkInfoProvider)))
|
..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));
|
..interceptors.add(ref.watch(errorTransformerInterceptorProvider));
|
||||||
|
|
||||||
return dio;
|
return dio;
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ final class DioProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$dioHash() => r'f15495e99d11744c245e2be892657748aeeb8ae7';
|
String _$dioHash() => r'40bb4b1008c8259c9db4b19bcee674aa6732810c';
|
||||||
|
|
||||||
/// Provider for DioClient
|
/// Provider for DioClient
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'package:worker/features/account/presentation/pages/addresses_page.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/change_password_page.dart';
|
||||||
import 'package:worker/features/account/presentation/pages/profile_edit_page.dart';
|
import 'package:worker/features/account/presentation/pages/profile_edit_page.dart';
|
||||||
import 'package:worker/features/auth/domain/entities/business_unit.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/design_request_detail_page.dart';
|
||||||
import 'package:worker/features/showrooms/presentation/pages/model_houses_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.
|
/// Provides GoRouter instance with auth state management
|
||||||
/// Features:
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
/// - Named routes for type-safe navigation
|
final authState = ref.watch(authProvider);
|
||||||
/// - Authentication guards (TODO: implement when auth is ready)
|
|
||||||
/// - Deep linking support
|
return GoRouter(
|
||||||
/// - Transition animations
|
|
||||||
class AppRouter {
|
|
||||||
/// Router configuration
|
|
||||||
static final GoRouter router = GoRouter(
|
|
||||||
// Initial route
|
// Initial route
|
||||||
initialLocation: RouteNames.login,
|
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
|
// Route definitions
|
||||||
routes: [
|
routes: [
|
||||||
// Authentication 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)
|
// Debug logging (disable in production)
|
||||||
debugLogDiagnostics: true,
|
debugLogDiagnostics: true,
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
/// Route Names
|
/// Route Names
|
||||||
///
|
///
|
||||||
|
|||||||
28
lib/core/services/frappe_auth_provider.dart
Normal file
28
lib/core/services/frappe_auth_provider.dart
Normal file
@@ -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);
|
||||||
|
}
|
||||||
67
lib/core/services/frappe_auth_provider.g.dart
Normal file
67
lib/core/services/frappe_auth_provider.g.dart
Normal file
@@ -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<FrappeAuthService> {
|
||||||
|
/// 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<FrappeAuthService> $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<FrappeAuthService>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$frappeAuthServiceHash() => r'73112c920895302df011517e81c97eef2b5df5ac';
|
||||||
245
lib/core/services/frappe_auth_service.dart
Normal file
245
lib/core/services/frappe_auth_service.dart
Normal file
@@ -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<FrappeSessionResponse> getSession() async {
|
||||||
|
try {
|
||||||
|
final url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetSession}';
|
||||||
|
|
||||||
|
final response = await _dio.post<Map<String, dynamic>>(
|
||||||
|
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<FrappeSessionResponse> 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<Map<String, dynamic>>(
|
||||||
|
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<void> _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<Map<String, String>?> 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<Map<String, String>> 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<bool> 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<Map<String, String>> 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<void> clearSession() async {
|
||||||
|
await Future.wait([
|
||||||
|
_secureStorage.delete(key: _keyFrappeSid),
|
||||||
|
_secureStorage.delete(key: _keyFrappeCsrfToken),
|
||||||
|
_secureStorage.delete(key: _keyFrappeFullName),
|
||||||
|
_secureStorage.delete(key: _keyFrappeUserId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ class AuthLocalDataSource {
|
|||||||
static const String _fullNameKey = 'auth_session_full_name';
|
static const String _fullNameKey = 'auth_session_full_name';
|
||||||
static const String _createdAtKey = 'auth_session_created_at';
|
static const String _createdAtKey = 'auth_session_created_at';
|
||||||
static const String _appsKey = 'auth_session_apps';
|
static const String _appsKey = 'auth_session_apps';
|
||||||
|
static const String _rememberMeKey = 'auth_remember_me';
|
||||||
|
|
||||||
AuthLocalDataSource(this._secureStorage);
|
AuthLocalDataSource(this._secureStorage);
|
||||||
|
|
||||||
@@ -102,21 +103,46 @@ class AuthLocalDataSource {
|
|||||||
return sid != null && csrfToken != null;
|
return sid != null && csrfToken != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Save "Remember Me" preference
|
||||||
|
///
|
||||||
|
/// If true, user session will be restored on next app launch.
|
||||||
|
Future<void> 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<bool> getRememberMe() async {
|
||||||
|
final value = await _secureStorage.read(key: _rememberMeKey);
|
||||||
|
return value == 'true';
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear session data
|
/// Clear session data
|
||||||
///
|
///
|
||||||
/// Called during logout to remove all session information.
|
/// Called during logout to remove all session information including rememberMe.
|
||||||
Future<void> clearSession() async {
|
Future<void> clearSession() async {
|
||||||
|
// Clear all session data including rememberMe
|
||||||
await _secureStorage.delete(key: _sidKey);
|
await _secureStorage.delete(key: _sidKey);
|
||||||
await _secureStorage.delete(key: _csrfTokenKey);
|
await _secureStorage.delete(key: _csrfTokenKey);
|
||||||
await _secureStorage.delete(key: _fullNameKey);
|
await _secureStorage.delete(key: _fullNameKey);
|
||||||
await _secureStorage.delete(key: _createdAtKey);
|
await _secureStorage.delete(key: _createdAtKey);
|
||||||
await _secureStorage.delete(key: _appsKey);
|
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.
|
/// Complete cleanup of all stored auth data.
|
||||||
Future<void> clearAll() async {
|
Future<void> 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<GetSessionResponse> login({
|
||||||
|
required String phone,
|
||||||
|
required String csrfToken,
|
||||||
|
required String sid,
|
||||||
|
String? password, // Reserved for future use
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.post<Map<String, dynamic>>(
|
||||||
|
'/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
|
/// Get Cities
|
||||||
///
|
///
|
||||||
/// Fetches list of cities/provinces for address selection.
|
/// Fetches list of cities/provinces for address selection.
|
||||||
|
|||||||
@@ -41,13 +41,16 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
// Controllers
|
// Controllers
|
||||||
final _phoneController = TextEditingController(text: "0988111111");
|
final _phoneController = TextEditingController(text: "0978113710");
|
||||||
final _passwordController = TextEditingController(text: "123456");
|
final _passwordController = TextEditingController(text: "123456");
|
||||||
|
|
||||||
// Focus nodes
|
// Focus nodes
|
||||||
final _phoneFocusNode = FocusNode();
|
final _phoneFocusNode = FocusNode();
|
||||||
final _passwordFocusNode = FocusNode();
|
final _passwordFocusNode = FocusNode();
|
||||||
|
|
||||||
|
// Remember me checkbox state
|
||||||
|
bool _rememberMe = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_phoneController.dispose();
|
_phoneController.dispose();
|
||||||
@@ -74,11 +77,12 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
.login(
|
.login(
|
||||||
phoneNumber: _phoneController.text.trim(),
|
phoneNumber: _phoneController.text.trim(),
|
||||||
password: _passwordController.text,
|
password: _passwordController.text,
|
||||||
|
rememberMe: _rememberMe,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if login was successful
|
// Check if login was successful
|
||||||
final authState = ref.read(authProvider);
|
final authState = ref.read(authProvider)
|
||||||
authState.when(
|
..when(
|
||||||
data: (user) {
|
data: (user) {
|
||||||
if (user != null && mounted) {
|
if (user != null && mounted) {
|
||||||
// Navigate to home on success
|
// Navigate to home on success
|
||||||
@@ -402,7 +406,45 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
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
|
// Login Button
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|||||||
@@ -6,9 +6,14 @@
|
|||||||
/// Uses Riverpod 3.0 with code generation for type-safe state management.
|
/// Uses Riverpod 3.0 with code generation for type-safe state management.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.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_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/data/models/auth_session_model.dart';
|
||||||
import 'package:worker/features/auth/domain/entities/user.dart';
|
import 'package:worker/features/auth/domain/entities/user.dart';
|
||||||
|
|
||||||
@@ -30,6 +35,21 @@ AuthLocalDataSource authLocalDataSource(Ref ref) {
|
|||||||
return AuthLocalDataSource(secureStorage);
|
return AuthLocalDataSource(secureStorage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Provide FrappeAuthService instance
|
||||||
|
@riverpod
|
||||||
|
Future<FrappeAuthService> 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> authRemoteDataSource(Ref ref) async {
|
||||||
|
final dio = await ref.watch(dioProvider.future);
|
||||||
|
return AuthRemoteDataSource(dio);
|
||||||
|
}
|
||||||
|
|
||||||
/// Authentication state result
|
/// Authentication state result
|
||||||
///
|
///
|
||||||
/// Represents the result of authentication operations.
|
/// Represents the result of authentication operations.
|
||||||
@@ -56,19 +76,43 @@ class Auth extends _$Auth {
|
|||||||
AuthLocalDataSource get _localDataSource =>
|
AuthLocalDataSource get _localDataSource =>
|
||||||
ref.read(authLocalDataSourceProvider);
|
ref.read(authLocalDataSourceProvider);
|
||||||
|
|
||||||
|
/// Get Frappe auth service
|
||||||
|
Future<FrappeAuthService> get _frappeAuthService async =>
|
||||||
|
await ref.read(frappeAuthServiceProvider.future);
|
||||||
|
|
||||||
|
/// Get auth remote data source
|
||||||
|
Future<AuthRemoteDataSource> get _remoteDataSource async =>
|
||||||
|
await ref.read(authRemoteDataSourceProvider.future);
|
||||||
|
|
||||||
/// Initialize with saved session if available
|
/// Initialize with saved session if available
|
||||||
@override
|
@override
|
||||||
Future<User?> build() async {
|
Future<User?> build() async {
|
||||||
// Check for saved session in secure storage
|
// Simple initialization - just check if user is logged in
|
||||||
final session = await _localDataSource.getSession();
|
// Don't call getSession() here to avoid ref disposal issues
|
||||||
if (session != null) {
|
try {
|
||||||
// User has saved session, create User entity
|
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();
|
final now = DateTime.now();
|
||||||
return User(
|
return User(
|
||||||
userId: 'user_saved', // TODO: Get from API
|
userId: userId,
|
||||||
phoneNumber: '', // TODO: Get from saved user data
|
phoneNumber: userId,
|
||||||
fullName: session.fullName,
|
fullName: fullName ?? 'User',
|
||||||
email: '', // TODO: Get from saved user data
|
email: '',
|
||||||
role: UserRole.customer,
|
role: UserRole.customer,
|
||||||
status: UserStatus.active,
|
status: UserStatus.active,
|
||||||
loyaltyTier: LoyaltyTier.gold,
|
loyaltyTier: LoyaltyTier.gold,
|
||||||
@@ -81,22 +125,33 @@ class Auth extends _$Auth {
|
|||||||
referralCode: null,
|
referralCode: null,
|
||||||
referredBy: null,
|
referredBy: null,
|
||||||
erpnextCustomerId: null,
|
erpnextCustomerId: null,
|
||||||
createdAt: session.createdAt,
|
createdAt: now.subtract(const Duration(days: 30)),
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
lastLoginAt: 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Login with phone number and password
|
/// Login with phone number
|
||||||
///
|
///
|
||||||
/// Simulates ERPNext API authentication with mock response.
|
/// Uses Frappe ERPNext API authentication flow:
|
||||||
/// Stores session data (SID, CSRF token) in Hive.
|
/// 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:
|
/// Parameters:
|
||||||
/// - [phoneNumber]: User's phone number (Vietnamese format)
|
/// - [phoneNumber]: User's phone number (Vietnamese format)
|
||||||
/// - [password]: User's password
|
/// - [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
|
/// Returns: Authenticated User object on success
|
||||||
///
|
///
|
||||||
@@ -104,69 +159,68 @@ class Auth extends _$Auth {
|
|||||||
Future<void> login({
|
Future<void> login({
|
||||||
required String phoneNumber,
|
required String phoneNumber,
|
||||||
required String password,
|
required String password,
|
||||||
|
bool rememberMe = false,
|
||||||
}) async {
|
}) async {
|
||||||
// Set loading state
|
// Set loading state
|
||||||
state = const AsyncValue.loading();
|
state = const AsyncValue.loading();
|
||||||
|
|
||||||
// Simulate API call delay
|
|
||||||
state = await AsyncValue.guard(() async {
|
state = await AsyncValue.guard(() async {
|
||||||
await Future<void>.delayed(const Duration(seconds: 2));
|
// Validation
|
||||||
|
if (phoneNumber.isEmpty) {
|
||||||
// Mock validation
|
throw Exception('Số điện thoại không được để trống');
|
||||||
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) {
|
final frappeService = await _frappeAuthService;
|
||||||
throw Exception('Mật khẩu phải có ít nhất 6 ký tự');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate API response matching ERPNext format
|
// Get stored session again
|
||||||
final mockApiResponse = AuthSessionResponse(
|
final session = await frappeService.getStoredSession();
|
||||||
sessionExpired: 1,
|
if (session == null) {
|
||||||
message: const LoginMessage(
|
throw Exception('Session not available');
|
||||||
success: true,
|
}
|
||||||
message: 'Login successful',
|
|
||||||
sid: 'df7fd4e7ef1041aa3422b0ee861315ba8c28d4fe008a7d7e0e7e0e01',
|
// Call login API with current session
|
||||||
csrfToken: '6b6e37563854e951c36a7af4177956bb15ca469ca4f498b742648d70',
|
final loginResponse = await remoteDataSource.login(
|
||||||
apps: [
|
phone: phoneNumber,
|
||||||
AppInfo(
|
csrfToken: session['csrfToken']!,
|
||||||
appTitle: 'App nhân viên kinh doanh',
|
sid: session['sid']!,
|
||||||
appEndpoint: '/ecommerce/app-sales',
|
password: password, // Reserved for future use
|
||||||
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
|
// Update FlutterSecureStorage with new authenticated session
|
||||||
final sessionData = SessionData.fromAuthResponse(mockApiResponse);
|
await frappeService.login(phoneNumber, password: password);
|
||||||
await _localDataSource.saveSession(sessionData);
|
|
||||||
|
// Save rememberMe preference
|
||||||
|
await _localDataSource.saveRememberMe(rememberMe);
|
||||||
|
|
||||||
// Create and return User entity
|
// Create and return User entity
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
return User(
|
return User(
|
||||||
userId: 'user_${phoneNumber.replaceAll('+84', '')}',
|
userId: phoneNumber,
|
||||||
phoneNumber: phoneNumber,
|
phoneNumber: phoneNumber,
|
||||||
fullName: mockApiResponse.fullName,
|
fullName: loginResponse.fullName,
|
||||||
email: 'user@eurotile.vn',
|
email: '',
|
||||||
role: UserRole.customer,
|
role: UserRole.customer,
|
||||||
status: UserStatus.active,
|
status: UserStatus.active,
|
||||||
loyaltyTier: LoyaltyTier.gold,
|
loyaltyTier: LoyaltyTier.gold,
|
||||||
totalPoints: 1500,
|
totalPoints: 0,
|
||||||
companyInfo: const CompanyInfo(
|
companyInfo: null,
|
||||||
name: 'Công ty TNHH XYZ',
|
cccd: null,
|
||||||
taxId: '0123456789',
|
|
||||||
businessType: 'Xây dựng',
|
|
||||||
),
|
|
||||||
cccd: '001234567890',
|
|
||||||
attachments: [],
|
attachments: [],
|
||||||
address: '123 Đường ABC, Quận 1, TP.HCM',
|
address: null,
|
||||||
avatarUrl: null,
|
avatarUrl: null,
|
||||||
referralCode: 'REF${phoneNumber.replaceAll('+84', '').substring(0, 6)}',
|
referralCode: null,
|
||||||
referredBy: null,
|
referredBy: null,
|
||||||
erpnextCustomerId: null,
|
erpnextCustomerId: null,
|
||||||
createdAt: now.subtract(const Duration(days: 30)),
|
createdAt: now.subtract(const Duration(days: 30)),
|
||||||
@@ -178,17 +232,20 @@ class Auth extends _$Auth {
|
|||||||
|
|
||||||
/// Logout current user
|
/// 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<void> logout() async {
|
Future<void> logout() async {
|
||||||
state = const AsyncValue.loading();
|
state = const AsyncValue.loading();
|
||||||
|
|
||||||
state = await AsyncValue.guard(() async {
|
state = await AsyncValue.guard(() async {
|
||||||
// Clear saved session from Hive
|
final frappeService = await _frappeAuthService;
|
||||||
|
|
||||||
|
// Clear saved session
|
||||||
await _localDataSource.clearSession();
|
await _localDataSource.clearSession();
|
||||||
|
await frappeService.clearSession();
|
||||||
|
|
||||||
// TODO: Call logout API to invalidate token on server
|
// Get new public session for registration/login
|
||||||
|
await frappeService.getSession();
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
// Return null to indicate logged out
|
// Return null to indicate logged out
|
||||||
return null;
|
return null;
|
||||||
@@ -277,3 +334,31 @@ int userTotalPoints(Ref ref) {
|
|||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
return user?.totalPoints ?? 0;
|
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<void> 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,6 +113,99 @@ final class AuthLocalDataSourceProvider
|
|||||||
String _$authLocalDataSourceHash() =>
|
String _$authLocalDataSourceHash() =>
|
||||||
r'f104de00a8ab431f6736387fb499c2b6e0ab4924';
|
r'f104de00a8ab431f6736387fb499c2b6e0ab4924';
|
||||||
|
|
||||||
|
/// Provide FrappeAuthService instance
|
||||||
|
|
||||||
|
@ProviderFor(frappeAuthService)
|
||||||
|
const frappeAuthServiceProvider = FrappeAuthServiceProvider._();
|
||||||
|
|
||||||
|
/// Provide FrappeAuthService instance
|
||||||
|
|
||||||
|
final class FrappeAuthServiceProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
AsyncValue<FrappeAuthService>,
|
||||||
|
FrappeAuthService,
|
||||||
|
FutureOr<FrappeAuthService>
|
||||||
|
>
|
||||||
|
with
|
||||||
|
$FutureModifier<FrappeAuthService>,
|
||||||
|
$FutureProvider<FrappeAuthService> {
|
||||||
|
/// 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<FrappeAuthService> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<FrappeAuthService> 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>,
|
||||||
|
AuthRemoteDataSource,
|
||||||
|
FutureOr<AuthRemoteDataSource>
|
||||||
|
>
|
||||||
|
with
|
||||||
|
$FutureModifier<AuthRemoteDataSource>,
|
||||||
|
$FutureProvider<AuthRemoteDataSource> {
|
||||||
|
/// 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<AuthRemoteDataSource> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<AuthRemoteDataSource> create(Ref ref) {
|
||||||
|
return authRemoteDataSource(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$authRemoteDataSourceHash() =>
|
||||||
|
r'3c05cf67fe479a973fc4ce2db68a0abde37974a5';
|
||||||
|
|
||||||
/// Authentication Provider
|
/// Authentication Provider
|
||||||
///
|
///
|
||||||
/// Main provider for authentication state management.
|
/// Main provider for authentication state management.
|
||||||
@@ -179,7 +272,7 @@ final class AuthProvider extends $AsyncNotifierProvider<Auth, User?> {
|
|||||||
Auth create() => Auth();
|
Auth create() => Auth();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$authHash() => r'6f410d1abe6c53a6cbfa52fde7ea7a2d22a7f78d';
|
String _$authHash() => r'3f0562ffb573be47d8aae8beebccb1946240cbb6';
|
||||||
|
|
||||||
/// Authentication Provider
|
/// Authentication Provider
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -103,24 +103,24 @@ class HomePage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Promotions Section
|
// Promotions Section
|
||||||
SliverToBoxAdapter(
|
// SliverToBoxAdapter(
|
||||||
child: promotionsAsync.when(
|
// child: promotionsAsync.when(
|
||||||
data: (promotions) => promotions.isNotEmpty
|
// data: (promotions) => promotions.isNotEmpty
|
||||||
? PromotionSlider(
|
// ? PromotionSlider(
|
||||||
promotions: promotions,
|
// promotions: promotions,
|
||||||
onPromotionTap: (promotion) {
|
// onPromotionTap: (promotion) {
|
||||||
// Navigate to promotion details
|
// // Navigate to promotion details
|
||||||
context.push('/promotions/${promotion.id}');
|
// context.push('/promotions/${promotion.id}');
|
||||||
},
|
// },
|
||||||
)
|
// )
|
||||||
: const SizedBox.shrink(),
|
// : const SizedBox.shrink(),
|
||||||
loading: () => const Padding(
|
// loading: () => const Padding(
|
||||||
padding: EdgeInsets.all(16),
|
// padding: EdgeInsets.all(16),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
// child: Center(child: CircularProgressIndicator()),
|
||||||
),
|
// ),
|
||||||
error: (error, stack) => const SizedBox.shrink(),
|
// error: (error, stack) => const SizedBox.shrink(),
|
||||||
),
|
// ),
|
||||||
),
|
// ),
|
||||||
|
|
||||||
// Quick Action Sections
|
// Quick Action Sections
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
|
|||||||
@@ -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<List<BlogCategoryModel>> 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<Map<String, dynamic>>(
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
lib/features/news/data/models/blog_category_model.dart
Normal file
128
lib/features/news/data/models/blog_category_model.dart
Normal file
@@ -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<String, dynamic> json) =>
|
||||||
|
_$BlogCategoryModelFromJson(json);
|
||||||
|
|
||||||
|
/// To JSON method
|
||||||
|
Map<String, dynamic> 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<BlogCategoryModel> message;
|
||||||
|
|
||||||
|
BlogCategoriesResponse({
|
||||||
|
required this.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// From JSON constructor
|
||||||
|
factory BlogCategoriesResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
final messageList = json['message'] as List<dynamic>;
|
||||||
|
final categories = messageList
|
||||||
|
.map((item) => BlogCategoryModel.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return BlogCategoriesResponse(message: categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// To JSON method
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'message': message.map((category) => category.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
19
lib/features/news/data/models/blog_category_model.g.dart
Normal file
19
lib/features/news/data/models/blog_category_model.g.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'blog_category_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
BlogCategoryModel _$BlogCategoryModelFromJson(Map<String, dynamic> 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<String, dynamic> _$BlogCategoryModelToJson(BlogCategoryModel instance) =>
|
||||||
|
<String, dynamic>{'title': instance.title, 'name': instance.name};
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:worker/features/news/data/datasources/news_local_datasource.dart';
|
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/entities/news_article.dart';
|
||||||
import 'package:worker/features/news/domain/repositories/news_repository.dart';
|
import 'package:worker/features/news/domain/repositories/news_repository.dart';
|
||||||
|
|
||||||
@@ -13,8 +15,30 @@ class NewsRepositoryImpl implements NewsRepository {
|
|||||||
/// Local data source
|
/// Local data source
|
||||||
final NewsLocalDataSource localDataSource;
|
final NewsLocalDataSource localDataSource;
|
||||||
|
|
||||||
|
/// Remote data source
|
||||||
|
final NewsRemoteDataSource remoteDataSource;
|
||||||
|
|
||||||
/// Constructor
|
/// Constructor
|
||||||
NewsRepositoryImpl({required this.localDataSource});
|
NewsRepositoryImpl({
|
||||||
|
required this.localDataSource,
|
||||||
|
required this.remoteDataSource,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<BlogCategory>> 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
|
@override
|
||||||
Future<List<NewsArticle>> getAllArticles() async {
|
Future<List<NewsArticle>> getAllArticles() async {
|
||||||
|
|||||||
98
lib/features/news/domain/entities/blog_category.dart
Normal file
98
lib/features/news/domain/entities/blog_category.dart
Normal file
@@ -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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,16 @@
|
|||||||
/// This is an abstract interface following the Repository Pattern.
|
/// This is an abstract interface following the Repository Pattern.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
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/entities/news_article.dart';
|
||||||
|
|
||||||
/// News Repository Interface
|
/// News Repository Interface
|
||||||
///
|
///
|
||||||
/// Provides methods to fetch and manage news articles.
|
/// Provides methods to fetch and manage news articles and categories.
|
||||||
abstract class NewsRepository {
|
abstract class NewsRepository {
|
||||||
|
/// Get all blog categories from Frappe API
|
||||||
|
Future<List<BlogCategory>> getBlogCategories();
|
||||||
|
|
||||||
/// Get all news articles
|
/// Get all news articles
|
||||||
Future<List<NewsArticle>> getAllArticles();
|
Future<List<NewsArticle>> getAllArticles();
|
||||||
|
|
||||||
|
|||||||
@@ -19,20 +19,36 @@ import 'package:worker/features/news/presentation/widgets/news_card.dart';
|
|||||||
///
|
///
|
||||||
/// Features:
|
/// Features:
|
||||||
/// - Standard AppBar with title "Tin tức & chuyên môn"
|
/// - 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)
|
/// - Featured article section (large card)
|
||||||
/// - "Mới nhất" section with news cards list
|
/// - "Mới nhất" section with news cards list
|
||||||
/// - RefreshIndicator for pull-to-refresh
|
/// - RefreshIndicator for pull-to-refresh
|
||||||
/// - Loading and error states
|
/// - Loading and error states
|
||||||
class NewsListPage extends ConsumerWidget {
|
class NewsListPage extends ConsumerStatefulWidget {
|
||||||
const NewsListPage({super.key});
|
const NewsListPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<NewsListPage> createState() => _NewsListPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NewsListPageState extends ConsumerState<NewsListPage> {
|
||||||
|
/// Currently selected category name (null = All)
|
||||||
|
String? selectedCategoryName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
// Watch providers
|
// Watch providers
|
||||||
final featuredArticleAsync = ref.watch(featuredArticleProvider);
|
final featuredArticleAsync = ref.watch(featuredArticleProvider);
|
||||||
final filteredArticlesAsync = ref.watch(filteredNewsArticlesProvider);
|
final newsArticlesAsync = ref.watch(newsArticlesProvider);
|
||||||
final selectedCategory = ref.watch(selectedNewsCategoryProvider);
|
|
||||||
|
// 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(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
@@ -40,9 +56,10 @@ class NewsListPage extends ConsumerWidget {
|
|||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
// Invalidate providers to trigger refresh
|
// Invalidate providers to trigger refresh
|
||||||
ref.invalidate(newsArticlesProvider);
|
ref
|
||||||
ref.invalidate(featuredArticleProvider);
|
..invalidate(newsArticlesProvider)
|
||||||
ref.invalidate(filteredNewsArticlesProvider);
|
..invalidate(featuredArticleProvider)
|
||||||
|
..invalidate(blogCategoriesProvider);
|
||||||
},
|
},
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -51,11 +68,11 @@ class NewsListPage extends ConsumerWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 4, bottom: AppSpacing.md),
|
padding: const EdgeInsets.only(top: 4, bottom: AppSpacing.md),
|
||||||
child: CategoryFilterChips(
|
child: CategoryFilterChips(
|
||||||
selectedCategory: selectedCategory,
|
selectedCategoryName: selectedCategoryName,
|
||||||
onCategorySelected: (category) {
|
onCategorySelected: (categoryName) {
|
||||||
ref
|
setState(() {
|
||||||
.read(selectedNewsCategoryProvider.notifier)
|
selectedCategoryName = categoryName;
|
||||||
.setCategory(category);
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -148,7 +165,7 @@ class NewsListPage extends ConsumerWidget {
|
|||||||
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
|
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
|
||||||
|
|
||||||
// News List
|
// News List
|
||||||
filteredArticlesAsync.when(
|
filteredArticles.when(
|
||||||
data: (articles) {
|
data: (articles) {
|
||||||
if (articles.isEmpty) {
|
if (articles.isEmpty) {
|
||||||
return SliverFillRemaining(child: _buildEmptyState());
|
return SliverFillRemaining(child: _buildEmptyState());
|
||||||
|
|||||||
@@ -5,10 +5,14 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
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/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/entities/news_article.dart';
|
||||||
import 'package:worker/features/news/domain/repositories/news_repository.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';
|
part 'news_provider.g.dart';
|
||||||
|
|
||||||
@@ -20,13 +24,27 @@ NewsLocalDataSource newsLocalDataSource(Ref ref) {
|
|||||||
return NewsLocalDataSource();
|
return NewsLocalDataSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// News Remote DataSource Provider
|
||||||
|
///
|
||||||
|
/// Provides instance of NewsRemoteDataSource with Frappe auth service.
|
||||||
|
@riverpod
|
||||||
|
Future<NewsRemoteDataSource> newsRemoteDataSource(Ref ref) async {
|
||||||
|
final dioClient = await ref.watch(dioClientProvider.future);
|
||||||
|
final frappeAuthService = ref.watch(frappeAuthServiceProvider);
|
||||||
|
return NewsRemoteDataSource(dioClient, frappeAuthService);
|
||||||
|
}
|
||||||
|
|
||||||
/// News Repository Provider
|
/// News Repository Provider
|
||||||
///
|
///
|
||||||
/// Provides instance of NewsRepository implementation.
|
/// Provides instance of NewsRepository implementation.
|
||||||
@riverpod
|
@riverpod
|
||||||
NewsRepository newsRepository(Ref ref) {
|
Future<NewsRepository> newsRepository(Ref ref) async {
|
||||||
final localDataSource = ref.watch(newsLocalDataSourceProvider);
|
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
|
/// News Articles Provider
|
||||||
@@ -35,7 +53,7 @@ NewsRepository newsRepository(Ref ref) {
|
|||||||
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
||||||
@riverpod
|
@riverpod
|
||||||
Future<List<NewsArticle>> newsArticles(Ref ref) async {
|
Future<List<NewsArticle>> newsArticles(Ref ref) async {
|
||||||
final repository = ref.watch(newsRepositoryProvider);
|
final repository = await ref.watch(newsRepositoryProvider.future);
|
||||||
return repository.getAllArticles();
|
return repository.getAllArticles();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +63,7 @@ Future<List<NewsArticle>> newsArticles(Ref ref) async {
|
|||||||
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
|
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
|
||||||
@riverpod
|
@riverpod
|
||||||
Future<NewsArticle?> featuredArticle(Ref ref) async {
|
Future<NewsArticle?> featuredArticle(Ref ref) async {
|
||||||
final repository = ref.watch(newsRepositoryProvider);
|
final repository = await ref.watch(newsRepositoryProvider.future);
|
||||||
return repository.getFeaturedArticle();
|
return repository.getFeaturedArticle();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +97,7 @@ class SelectedNewsCategory extends _$SelectedNewsCategory {
|
|||||||
@riverpod
|
@riverpod
|
||||||
Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
|
Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
|
||||||
final selectedCategory = ref.watch(selectedNewsCategoryProvider);
|
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 no category selected, return all articles
|
||||||
if (selectedCategory == null) {
|
if (selectedCategory == null) {
|
||||||
@@ -96,6 +114,22 @@ Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
|
|||||||
/// Used for article detail page.
|
/// Used for article detail page.
|
||||||
@riverpod
|
@riverpod
|
||||||
Future<NewsArticle?> newsArticleById(Ref ref, String articleId) async {
|
Future<NewsArticle?> newsArticleById(Ref ref, String articleId) async {
|
||||||
final repository = ref.watch(newsRepositoryProvider);
|
final repository = await ref.watch(newsRepositoryProvider.future);
|
||||||
return repository.getArticleById(articleId);
|
return repository.getArticleById(articleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Blog Categories Provider
|
||||||
|
///
|
||||||
|
/// Fetches all published blog categories from Frappe API.
|
||||||
|
/// Returns AsyncValue<List<BlogCategory>> (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<List<BlogCategory>> blogCategories(Ref ref) async {
|
||||||
|
final repository = await ref.watch(newsRepositoryProvider.future);
|
||||||
|
return repository.getBlogCategories();
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,59 @@ final class NewsLocalDataSourceProvider
|
|||||||
String _$newsLocalDataSourceHash() =>
|
String _$newsLocalDataSourceHash() =>
|
||||||
r'e7e7d71d20274fe8b498c7b15f8aeb9eb515af27';
|
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>,
|
||||||
|
NewsRemoteDataSource,
|
||||||
|
FutureOr<NewsRemoteDataSource>
|
||||||
|
>
|
||||||
|
with
|
||||||
|
$FutureModifier<NewsRemoteDataSource>,
|
||||||
|
$FutureProvider<NewsRemoteDataSource> {
|
||||||
|
/// 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<NewsRemoteDataSource> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<NewsRemoteDataSource> create(Ref ref) {
|
||||||
|
return newsRemoteDataSource(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$newsRemoteDataSourceHash() =>
|
||||||
|
r'27db8dc4fadf806349fe4f0ad5fed1999620c1a3';
|
||||||
|
|
||||||
/// News Repository Provider
|
/// News Repository Provider
|
||||||
///
|
///
|
||||||
/// Provides instance of NewsRepository implementation.
|
/// Provides instance of NewsRepository implementation.
|
||||||
@@ -79,8 +132,13 @@ const newsRepositoryProvider = NewsRepositoryProvider._();
|
|||||||
/// Provides instance of NewsRepository implementation.
|
/// Provides instance of NewsRepository implementation.
|
||||||
|
|
||||||
final class NewsRepositoryProvider
|
final class NewsRepositoryProvider
|
||||||
extends $FunctionalProvider<NewsRepository, NewsRepository, NewsRepository>
|
extends
|
||||||
with $Provider<NewsRepository> {
|
$FunctionalProvider<
|
||||||
|
AsyncValue<NewsRepository>,
|
||||||
|
NewsRepository,
|
||||||
|
FutureOr<NewsRepository>
|
||||||
|
>
|
||||||
|
with $FutureModifier<NewsRepository>, $FutureProvider<NewsRepository> {
|
||||||
/// News Repository Provider
|
/// News Repository Provider
|
||||||
///
|
///
|
||||||
/// Provides instance of NewsRepository implementation.
|
/// Provides instance of NewsRepository implementation.
|
||||||
@@ -100,24 +158,17 @@ final class NewsRepositoryProvider
|
|||||||
|
|
||||||
@$internal
|
@$internal
|
||||||
@override
|
@override
|
||||||
$ProviderElement<NewsRepository> $createElement($ProviderPointer pointer) =>
|
$FutureProviderElement<NewsRepository> $createElement(
|
||||||
$ProviderElement(pointer);
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
NewsRepository create(Ref ref) {
|
FutureOr<NewsRepository> create(Ref ref) {
|
||||||
return newsRepository(ref);
|
return newsRepository(ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// {@macro riverpod.override_with_value}
|
|
||||||
Override overrideWithValue(NewsRepository value) {
|
|
||||||
return $ProviderOverride(
|
|
||||||
origin: this,
|
|
||||||
providerOverride: $SyncValueProvider<NewsRepository>(value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$newsRepositoryHash() => r'1536188fae6934f147f022a8f5d7bd62ff9453b5';
|
String _$newsRepositoryHash() => r'8e66d847014926ad542e402874e52d35b00cdbcc';
|
||||||
|
|
||||||
/// News Articles Provider
|
/// News Articles Provider
|
||||||
///
|
///
|
||||||
@@ -172,7 +223,7 @@ final class NewsArticlesProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$newsArticlesHash() => r'24d70e49f7137c614c024dc93c97451c6e161ce6';
|
String _$newsArticlesHash() => r'789d916f1ce7d76f26429cfce97c65a71915edf3';
|
||||||
|
|
||||||
/// Featured Article Provider
|
/// Featured Article Provider
|
||||||
///
|
///
|
||||||
@@ -225,7 +276,7 @@ final class FeaturedArticleProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$featuredArticleHash() => r'f7146600bc3bbaf5987ab6b09262135b1558f1c0';
|
String _$featuredArticleHash() => r'5fd7057d3f828d6f717b08d59561aa9637eb0097';
|
||||||
|
|
||||||
/// Selected News Category Provider
|
/// Selected News Category Provider
|
||||||
///
|
///
|
||||||
@@ -353,7 +404,7 @@ final class FilteredNewsArticlesProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$filteredNewsArticlesHash() =>
|
String _$filteredNewsArticlesHash() =>
|
||||||
r'f40a737b74b44f2d4fa86977175314ed0da471fa';
|
r'f5d6faa2d510eae188f12fa41d052eeb43e08cc9';
|
||||||
|
|
||||||
/// News Article by ID Provider
|
/// 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
|
/// News Article by ID Provider
|
||||||
///
|
///
|
||||||
@@ -453,3 +504,76 @@ final class NewsArticleByIdFamily extends $Family
|
|||||||
@override
|
@override
|
||||||
String toString() => r'newsArticleByIdProvider';
|
String toString() => r'newsArticleByIdProvider';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Blog Categories Provider
|
||||||
|
///
|
||||||
|
/// Fetches all published blog categories from Frappe API.
|
||||||
|
/// Returns AsyncValue<List<BlogCategory>> (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<List<BlogCategory>> (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<BlogCategory>>,
|
||||||
|
List<BlogCategory>,
|
||||||
|
FutureOr<List<BlogCategory>>
|
||||||
|
>
|
||||||
|
with
|
||||||
|
$FutureModifier<List<BlogCategory>>,
|
||||||
|
$FutureProvider<List<BlogCategory>> {
|
||||||
|
/// Blog Categories Provider
|
||||||
|
///
|
||||||
|
/// Fetches all published blog categories from Frappe API.
|
||||||
|
/// Returns AsyncValue<List<BlogCategory>> (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<List<BlogCategory>> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<List<BlogCategory>> create(Ref ref) {
|
||||||
|
return blogCategories(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$blogCategoriesHash() => r'd87493142946be20ab309ea94d6173a8005b516e';
|
||||||
|
|||||||
@@ -2,37 +2,53 @@
|
|||||||
///
|
///
|
||||||
/// Horizontal scrollable list of category filter chips.
|
/// Horizontal scrollable list of category filter chips.
|
||||||
/// Used in news list page for filtering articles by category.
|
/// Used in news list page for filtering articles by category.
|
||||||
|
/// Fetches categories dynamically from the Frappe API.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:worker/core/constants/ui_constants.dart';
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/theme/colors.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
|
/// Category Filter Chips
|
||||||
///
|
///
|
||||||
/// Displays a horizontal scrollable row of filter chips for news categories.
|
/// Displays a horizontal scrollable row of filter chips for news categories.
|
||||||
/// Features:
|
/// Features:
|
||||||
/// - "Tất cả" (All) option to show all categories
|
/// - "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)
|
/// - Active state styling (primary blue background, white text)
|
||||||
/// - Inactive state styling (grey background, grey text)
|
/// - Inactive state styling (grey background, grey text)
|
||||||
class CategoryFilterChips extends StatelessWidget {
|
/// - Loading state with shimmer effect
|
||||||
/// Currently selected category (null = All)
|
/// - Error state with retry button
|
||||||
final NewsCategory? selectedCategory;
|
class CategoryFilterChips extends ConsumerWidget {
|
||||||
|
/// Currently selected category name (null = All)
|
||||||
|
final String? selectedCategoryName;
|
||||||
|
|
||||||
/// Callback when a category is tapped
|
/// Callback when a category is tapped (passes category name)
|
||||||
final void Function(NewsCategory? category) onCategorySelected;
|
final void Function(String? categoryName) onCategorySelected;
|
||||||
|
|
||||||
/// Constructor
|
/// Constructor
|
||||||
const CategoryFilterChips({
|
const CategoryFilterChips({
|
||||||
super.key,
|
super.key,
|
||||||
required this.selectedCategory,
|
required this.selectedCategoryName,
|
||||||
required this.onCategorySelected,
|
required this.onCategorySelected,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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<BlogCategory> categories) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
@@ -41,20 +57,20 @@ class CategoryFilterChips extends StatelessWidget {
|
|||||||
// "Tất cả" chip
|
// "Tất cả" chip
|
||||||
_buildCategoryChip(
|
_buildCategoryChip(
|
||||||
label: 'Tất cả',
|
label: 'Tất cả',
|
||||||
isSelected: selectedCategory == null,
|
isSelected: selectedCategoryName == null,
|
||||||
onTap: () => onCategorySelected(null),
|
onTap: () => onCategorySelected(null),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
|
||||||
// Category chips
|
// Dynamic category chips from API
|
||||||
...NewsCategory.values.map((category) {
|
...categories.map((category) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
||||||
child: _buildCategoryChip(
|
child: _buildCategoryChip(
|
||||||
label: category.displayName,
|
label: category.title,
|
||||||
isSelected: selectedCategory == category,
|
isSelected: selectedCategoryName == category.name,
|
||||||
onTap: () => onCategorySelected(category),
|
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
|
/// Build individual category chip
|
||||||
Widget _buildCategoryChip({
|
Widget _buildCategoryChip({
|
||||||
required String label,
|
required String label,
|
||||||
|
|||||||
@@ -353,6 +353,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.2"
|
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:
|
dio_web_adapter:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ dependencies:
|
|||||||
connectivity_plus: ^6.0.3
|
connectivity_plus: ^6.0.3
|
||||||
pretty_dio_logger: ^1.3.1
|
pretty_dio_logger: ^1.3.1
|
||||||
curl_logger_dio_interceptor: ^1.0.0
|
curl_logger_dio_interceptor: ^1.0.0
|
||||||
|
dio_intercept_to_curl: ^0.2.0
|
||||||
dio_cache_interceptor: ^3.5.0
|
dio_cache_interceptor: ^3.5.0
|
||||||
dio_cache_interceptor_hive_store: ^3.2.2
|
dio_cache_interceptor_hive_store: ^3.2.2
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user