Compare commits

...

2 Commits

Author SHA1 Message Date
Phuoc Nguyen
67fd5ed142 update news 2025-11-10 15:37:55 +07:00
Phuoc Nguyen
36bdf6613b add auth 2025-11-10 14:21:27 +07:00
38 changed files with 3172 additions and 413 deletions

447
docs/AUTH_FLOW.md Normal file
View 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`.**

View File

@@ -56,3 +56,15 @@ curl --location 'https://land.dbiz.com//api/method/building_material.building_ma
"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
}'

35
docs/blog.sh Normal file
View File

@@ -0,0 +1,35 @@
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
}'
GET LIST BLOG
curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \
--header 'Cookie: sid=3976ecf8b1f2708cb6ec569d56addec1dcfadb9321201e84648eb6a3; full_name=PublicAPI; sid=3976ecf8b1f2708cb6ec569d56addec1dcfadb9321201e84648eb6a3; system_user=no; user_id=public_api%40dbiz.com; user_image=' \
--header 'X-Frappe-Csrf-Token: 79e51e95363a0c697f50c50b2ac8d6bb90d81ca6c4170da4296da292' \
--header 'Content-Type: application/json' \
--data '{
"doctype": "Blog Post",
"fields": ["name","title","published_on","blogger","blog_intro","content","meta_image","meta_description","blog_category"],
"filters": {"published":1},
"order_by" : "published_on desc",
"limit_page_length": 0
}'
blog detail
curl --location 'https://land.dbiz.com//api/method/frappe.client.get' \
--header 'Cookie: sid=18b0b29f511c1a2f4ea33a110fd9839a0da833a051a6ca30d2b387f9; full_name=PublicAPI; sid=723d7a4c28209a1c5451d2dce1f7232c04addb2e040a273f3a56ea77; system_user=no; user_id=public_api%40dbiz.com; user_image=' \
--header 'X-Frappe-Csrf-Token: 2b039c0e717027480d1faff125aeece598f65a2a822858e12e5c107a' \
--header 'Content-Type: application/json' \
--data '{
"doctype": "Blog Post",
"name" : "thông-báo-chương-trình-mua-gạch-eurotile-tặng-keo-chà-ron-và-keo-dán-gạch"
}'

View File

@@ -19,6 +19,9 @@ class WorkerApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch router provider to get auth-aware router
final router = ref.watch(routerProvider);
return MaterialApp.router(
// ==================== App Configuration ====================
debugShowCheckedModeBanner: false,
@@ -26,7 +29,8 @@ class WorkerApp extends ConsumerWidget {
// ==================== Router Configuration ====================
// Using go_router for declarative routing with deep linking support
routerConfig: AppRouter.router,
// Router is provided by Riverpod and includes auth state management
routerConfig: router,
// ==================== Theme Configuration ====================
// Material 3 theme with brand colors (Primary Blue: #005B9A)

View File

@@ -10,23 +10,11 @@ class ApiConstants {
// Base URLs
// ============================================================================
/// Base URL for development environment
static const String devBaseUrl = 'https://dev-api.worker.example.com';
/// Base URL for all APIs (Frappe/ERPNext)
static const String baseUrl = 'https://land.dbiz.com';
/// Base URL for staging environment
static const String stagingBaseUrl = 'https://staging-api.worker.example.com';
/// Base URL for production environment
static const String prodBaseUrl = 'https://api.worker.example.com';
/// Current base URL (should be configured based on build flavor)
static const String baseUrl = devBaseUrl; // TODO: Configure with flavors
/// API version prefix
static const String apiVersion = '/v1';
/// Full API base URL with version
static String get apiBaseUrl => '$baseUrl$apiVersion';
/// Full API base URL (no version prefix, using Frappe endpoints)
static String get apiBaseUrl => baseUrl;
// ============================================================================
// Timeout Configurations
@@ -347,6 +335,39 @@ class ApiConstants {
/// POST /promotions/{promotionId}/claim
static const String claimPromotion = '/promotions';
// ============================================================================
// Frappe/ERPNext API Endpoints
// ============================================================================
/// Frappe API method prefix
static const String frappeApiMethod = '/api/method';
/// Get Frappe session (public API, no auth required)
/// POST /api/method/dbiz_common.dbiz_common.api.auth.get_session
/// Returns: { "message": { "data": { "sid": "...", "csrf_token": "..." } }, "full_name": "..." }
static const String frappeGetSession = '/dbiz_common.dbiz_common.api.auth.get_session';
/// Login with phone (requires session sid and csrf_token)
/// POST /api/method/building_material.building_material.api.auth.login
/// Body: { "username": "phone", "googleid": null, "facebookid": null, "zaloid": null }
/// Returns: { "message": { "data": { "sid": "...", "csrf_token": "..." } }, "full_name": "..." }
static const String frappeLogin = '/building_material.building_material.api.auth.login';
/// Frappe client get_list (requires sid and csrf_token)
/// POST /api/method/frappe.client.get_list
static const String frappeGetList = '/frappe.client.get_list';
/// Frappe client get (requires sid and csrf_token)
/// POST /api/method/frappe.client.get
static const String frappeGet = '/frappe.client.get';
/// Register user (requires session sid and csrf_token)
/// POST /api/method/building_material.building_material.api.user.register
static const String frappeRegister = '/building_material.building_material.api.user.register';
/// Frappe public API user ID
static const String frappePublicUserId = 'public_api@dbiz.com';
// ============================================================================
// Notification Endpoints
// ============================================================================
@@ -380,8 +401,8 @@ class ApiConstants {
///
/// Example:
/// ```dart
/// final url = ApiConstants.buildUrl('/products', {'page': '1', 'limit': '20'});
/// // Returns: https://api.worker.example.com/v1/products?page=1&limit=20
/// final url = ApiConstants.buildUrl('/api/method/frappe.client.get_list', {'doctype': 'Item'});
/// // Returns: https://land.dbiz.com/api/method/frappe.client.get_list?doctype=Item
/// ```
static String buildUrl(String endpoint, [Map<String, String>? queryParams]) {
final uri = Uri.parse('$apiBaseUrl$endpoint');
@@ -395,8 +416,8 @@ class ApiConstants {
///
/// Example:
/// ```dart
/// final url = ApiConstants.buildUrlWithParams('/products/{id}', {'id': '123'});
/// // Returns: https://api.worker.example.com/v1/products/123
/// final url = ApiConstants.buildUrlWithParams('/api/resource/Item/{id}', {'id': '123'});
/// // Returns: https://land.dbiz.com/api/resource/Item/123
/// ```
static String buildUrlWithParams(
String endpoint,

View 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;
}

View 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,
};

View File

@@ -15,7 +15,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/errors/exceptions.dart';
import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart';
part 'api_interceptor.g.dart';
@@ -35,15 +34,16 @@ class AuthStorageKeys {
// Auth Interceptor
// ============================================================================
/// Interceptor for adding ERPNext session tokens to requests
/// Interceptor for adding Frappe/ERPNext session tokens to requests
///
/// Adds SID (Session ID) and CSRF token from Hive storage to request headers.
/// Adds Cookie (with SID) and X-Frappe-CSRF-Token from FlutterSecureStorage.
/// Uses the centralized FrappeAuthService for session management.
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._prefs, this._dio, this._authLocalDataSource);
AuthInterceptor(this._prefs, this._dio, this._secureStorage);
final SharedPreferences _prefs;
final Dio _dio;
final AuthLocalDataSource _authLocalDataSource;
final FlutterSecureStorage _secureStorage;
@override
void onRequest(
@@ -52,13 +52,24 @@ class AuthInterceptor extends Interceptor {
) async {
// Check if this endpoint requires authentication
if (_requiresAuth(options.path)) {
// Get session data from secure storage (async)
final sid = await _authLocalDataSource.getSid();
final csrfToken = await _authLocalDataSource.getCsrfToken();
// Get session data from secure storage
final sid = await _secureStorage.read(key: 'frappe_sid');
final csrfToken = await _secureStorage.read(key: 'frappe_csrf_token');
final fullName = await _secureStorage.read(key: 'frappe_full_name');
final userId = await _secureStorage.read(key: 'frappe_user_id');
if (sid != null && csrfToken != null) {
// Add ERPNext session headers
options.headers['Cookie'] = 'sid=$sid';
// Build cookie header with all required fields
final cookieHeader = [
'sid=$sid',
'full_name=${fullName ?? "User"}',
'system_user=no',
'user_id=${userId != null ? Uri.encodeComponent(userId) : ApiConstants.frappePublicUserId}',
'user_image=',
].join('; ');
// Add Frappe session headers
options.headers['Cookie'] = cookieHeader;
options.headers['X-Frappe-CSRF-Token'] = csrfToken;
}
@@ -276,9 +287,20 @@ class LoggingInterceptor extends Interceptor {
name: 'HTTP Response',
);
developer.log(
'Data: ${_truncateData(response.data, 500)}',
'Headers: ${response.headers.map}',
name: 'HTTP Response',
);
// Log full response data (not truncated for debugging)
final responseData = response.data;
if (responseData != null) {
if (responseData is String) {
developer.log('║ Response: $responseData', name: 'HTTP Response');
} else {
developer.log('║ Response: ${_truncateData(responseData, 2000)}', name: 'HTTP Response');
}
}
developer.log(
'╚══════════════════════════════════════════════════════════════',
name: 'HTTP Response',
@@ -534,14 +556,13 @@ Future<SharedPreferences> sharedPreferences(Ref ref) async {
Future<AuthInterceptor> authInterceptor(Ref ref, Dio dio) async {
final prefs = await ref.watch(sharedPreferencesProvider.future);
// Create AuthLocalDataSource with FlutterSecureStorage
// Use FlutterSecureStorage for Frappe session
const secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
final authLocalDataSource = AuthLocalDataSource(secureStorage);
return AuthInterceptor(prefs, dio, authLocalDataSource);
return AuthInterceptor(prefs, dio, secureStorage);
}
/// Provider for LoggingInterceptor

View File

@@ -114,7 +114,7 @@ final class AuthInterceptorProvider
}
}
String _$authInterceptorHash() => r'3f964536e03e204d09cc9120dd9d961b6d6d4b71';
String _$authInterceptorHash() => r'1221aab024b7c4d9fd393f7681f3ba094286a375';
/// Provider for AuthInterceptor

View File

@@ -8,6 +8,7 @@
/// - Retry logic
library;
import 'package:curl_logger_dio_interceptor/curl_logger_dio_interceptor.dart';
import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
@@ -382,19 +383,21 @@ Future<Dio> dio(Ref ref) async {
},
)
// Add interceptors in order
// 1. Logging interceptor (first to log everything)
// 1. Curl interceptor (first to log cURL commands)
..interceptors.add(CurlLoggerDioInterceptor())
// 2. Logging interceptor
..interceptors.add(ref.watch(loggingInterceptorProvider))
// 2. Auth interceptor (add tokens to requests)
// 3. Auth interceptor (add tokens to requests)
..interceptors.add(await ref.watch(authInterceptorProvider(dio).future))
// 3. Cache interceptor
// 4. Cache interceptor
..interceptors.add(
DioCacheInterceptor(
options: await ref.watch(cacheOptionsProvider.future),
),
)
// 4. Retry interceptor
// 5. Retry interceptor
..interceptors.add(RetryInterceptor(ref.watch(networkInfoProvider)))
// 5. Error transformer (last to transform all errors)
// 6. Error transformer (last to transform all errors)
..interceptors.add(ref.watch(errorTransformerInterceptorProvider));
return dio;

View File

@@ -131,7 +131,7 @@ final class DioProvider
}
}
String _$dioHash() => r'f15495e99d11744c245e2be892657748aeeb8ae7';
String _$dioHash() => r'40bb4b1008c8259c9db4b19bcee674aa6732810c';
/// Provider for DioClient

View File

@@ -5,9 +5,11 @@
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/features/account/presentation/pages/addresses_page.dart';
import 'package:worker/features/auth/presentation/providers/auth_provider.dart';
import 'package:worker/features/account/presentation/pages/change_password_page.dart';
import 'package:worker/features/account/presentation/pages/profile_edit_page.dart';
import 'package:worker/features/auth/domain/entities/business_unit.dart';
@@ -15,6 +17,7 @@ import 'package:worker/features/auth/presentation/pages/business_unit_selection_
import 'package:worker/features/auth/presentation/pages/login_page.dart';
import 'package:worker/features/auth/presentation/pages/otp_verification_page.dart';
import 'package:worker/features/auth/presentation/pages/register_page.dart';
import 'package:worker/features/auth/presentation/pages/splash_page.dart';
import 'package:worker/features/cart/presentation/pages/cart_page.dart';
import 'package:worker/features/cart/presentation/pages/checkout_page.dart';
import 'package:worker/features/chat/presentation/pages/chat_list_page.dart';
@@ -39,22 +42,63 @@ import 'package:worker/features/showrooms/presentation/pages/design_request_crea
import 'package:worker/features/showrooms/presentation/pages/design_request_detail_page.dart';
import 'package:worker/features/showrooms/presentation/pages/model_houses_page.dart';
/// App Router
/// Router Provider
///
/// Handles navigation throughout the app using declarative routing.
/// Features:
/// - Named routes for type-safe navigation
/// - Authentication guards (TODO: implement when auth is ready)
/// - Deep linking support
/// - Transition animations
class AppRouter {
/// Router configuration
static final GoRouter router = GoRouter(
// Initial route
initialLocation: RouteNames.login,
/// Provides GoRouter instance with auth state management
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authProvider);
return GoRouter(
// Initial route - start with splash screen
initialLocation: RouteNames.splash,
// Redirect based on auth state
redirect: (context, state) {
final isLoading = authState.isLoading;
final isLoggedIn = authState.value != null;
final isOnSplashPage = state.matchedLocation == RouteNames.splash;
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;
// While loading auth state, show splash screen
if (isLoading) {
return RouteNames.splash;
}
// After loading, redirect from splash to appropriate page
if (isOnSplashPage && !isLoading) {
return isLoggedIn ? RouteNames.home : RouteNames.login;
}
// If not logged in and not on auth/splash pages, redirect to login
if (!isLoggedIn && !isOnAuthPage && !isOnSplashPage) {
return RouteNames.login;
}
// If logged in and on login page, redirect to home
if (isLoggedIn && isOnLoginPage) {
return RouteNames.home;
}
// No redirect needed
return null;
},
// Route definitions
routes: [
// Splash Screen Route
GoRoute(
path: RouteNames.splash,
name: RouteNames.splash,
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const SplashPage()),
),
// Authentication Routes
GoRoute(
path: RouteNames.login,
@@ -384,26 +428,10 @@ class AppRouter {
),
),
// Redirect logic for authentication (TODO: implement when auth is ready)
// redirect: (context, state) {
// final isLoggedIn = false; // TODO: Get from auth provider
// final isOnLoginPage = state.matchedLocation == RouteNames.login;
//
// if (!isLoggedIn && !isOnLoginPage) {
// return RouteNames.login;
// }
//
// if (isLoggedIn && isOnLoginPage) {
// return RouteNames.home;
// }
//
// return null;
// },
// Debug logging (disable in production)
debugLogDiagnostics: true,
);
}
});
/// Route Names
///
@@ -479,7 +507,8 @@ class RouteNames {
'/model-houses/design-request/create';
static const String designRequestDetail = '/model-houses/design-request/:id';
// Authentication Routes (TODO: implement when auth feature is ready)
// Authentication Routes
static const String splash = '/splash';
static const String login = '/login';
static const String otpVerification = '/otp-verification';
static const String register = '/register';

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

View 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';

View 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),
]);
}
}

View File

@@ -20,6 +20,7 @@ class AuthLocalDataSource {
static const String _fullNameKey = 'auth_session_full_name';
static const String _createdAtKey = 'auth_session_created_at';
static const String _appsKey = 'auth_session_apps';
static const String _rememberMeKey = 'auth_remember_me';
AuthLocalDataSource(this._secureStorage);
@@ -102,21 +103,46 @@ class AuthLocalDataSource {
return sid != null && csrfToken != null;
}
/// Save "Remember Me" preference
///
/// If true, user session will be restored on next app launch.
Future<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
///
/// Called during logout to remove all session information.
/// Called during logout to remove all session information including rememberMe.
Future<void> clearSession() async {
// Clear all session data including rememberMe
await _secureStorage.delete(key: _sidKey);
await _secureStorage.delete(key: _csrfTokenKey);
await _secureStorage.delete(key: _fullNameKey);
await _secureStorage.delete(key: _createdAtKey);
await _secureStorage.delete(key: _appsKey);
await _secureStorage.delete(key: _rememberMeKey);
}
/// Clear all authentication data
/// Clear all authentication data including remember me
///
/// Complete cleanup of all stored auth data.
Future<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);
}
}

View File

@@ -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
///
/// Fetches list of cities/provinces for address selection.

View File

@@ -41,13 +41,16 @@ class _LoginPageState extends ConsumerState<LoginPage> {
final _formKey = GlobalKey<FormState>();
// Controllers
final _phoneController = TextEditingController(text: "0988111111");
final _phoneController = TextEditingController(text: "0978113710");
final _passwordController = TextEditingController(text: "123456");
// Focus nodes
final _phoneFocusNode = FocusNode();
final _passwordFocusNode = FocusNode();
// Remember me checkbox state
bool _rememberMe = true;
@override
void dispose() {
_phoneController.dispose();
@@ -74,11 +77,12 @@ class _LoginPageState extends ConsumerState<LoginPage> {
.login(
phoneNumber: _phoneController.text.trim(),
password: _passwordController.text,
rememberMe: _rememberMe,
);
// Check if login was successful
final authState = ref.read(authProvider);
authState.when(
final authState = ref.read(authProvider)
..when(
data: (user) {
if (user != null && mounted) {
// Navigate to home on success
@@ -402,7 +406,45 @@ class _LoginPageState extends ConsumerState<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
SizedBox(

View File

@@ -0,0 +1,82 @@
/// Splash Screen Page
///
/// Displays while checking authentication state on app startup.
library;
import 'package:flutter/material.dart';
import 'package:worker/core/theme/colors.dart';
/// Splash Page
///
/// Shows a loading screen while the app checks for stored authentication.
/// This prevents the brief flash of login page before redirecting to home.
class SplashPage extends StatelessWidget {
const SplashPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.white,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo
Container(
padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 20.0),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.primaryBlue, AppColors.lightBlue],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20.0),
),
child: const Column(
children: [
Text(
'EUROTILE',
style: TextStyle(
color: AppColors.white,
fontSize: 32.0,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
),
),
SizedBox(height: 4.0),
Text(
'Worker App',
style: TextStyle(
color: AppColors.white,
fontSize: 12.0,
letterSpacing: 0.5,
),
),
],
),
),
const SizedBox(height: 48.0),
// Loading Indicator
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryBlue),
strokeWidth: 3.0,
),
const SizedBox(height: 16.0),
// Loading Text
const Text(
'Đang tải...',
style: TextStyle(
fontSize: 14.0,
color: AppColors.grey500,
),
),
],
),
),
);
}
}

View File

@@ -6,9 +6,14 @@
/// Uses Riverpod 3.0 with code generation for type-safe state management.
library;
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/network/dio_client.dart';
import 'package:worker/core/services/frappe_auth_service.dart';
import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart';
import 'package:worker/features/auth/data/datasources/auth_remote_datasource.dart';
import 'package:worker/features/auth/data/models/auth_session_model.dart';
import 'package:worker/features/auth/domain/entities/user.dart';
@@ -30,6 +35,21 @@ AuthLocalDataSource authLocalDataSource(Ref ref) {
return AuthLocalDataSource(secureStorage);
}
/// Provide FrappeAuthService instance
@riverpod
Future<FrappeAuthService> 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
///
/// Represents the result of authentication operations.
@@ -56,19 +76,141 @@ class Auth extends _$Auth {
AuthLocalDataSource get _localDataSource =>
ref.read(authLocalDataSourceProvider);
/// Get Frappe auth service
Future<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
@override
Future<User?> build() async {
// Check for saved session in secure storage
final session = await _localDataSource.getSession();
if (session != null) {
// User has saved session, create User entity
// Simple initialization - just check if user is logged in
// Don't call getSession() here to avoid ref disposal issues
try {
final secureStorage = ref.read(secureStorageProvider);
// Check if "Remember Me" was enabled
final rememberMe = await _localDataSource.getRememberMe();
if (!rememberMe) {
// User didn't check "Remember Me", don't restore session
return null;
}
// Check if we have a stored session
final sid = await secureStorage.read(key: 'frappe_sid');
final userId = await secureStorage.read(key: 'frappe_user_id');
final fullName = await secureStorage.read(key: 'frappe_full_name');
if (sid != null && userId != null && userId != ApiConstants.frappePublicUserId) {
// User is logged in and wants to be remembered, create User entity
final now = DateTime.now();
return User(
userId: userId,
phoneNumber: userId,
fullName: fullName ?? 'User',
email: '',
role: UserRole.customer,
status: UserStatus.active,
loyaltyTier: LoyaltyTier.gold,
totalPoints: 0,
companyInfo: null,
cccd: null,
attachments: [],
address: null,
avatarUrl: null,
referralCode: null,
referredBy: null,
erpnextCustomerId: null,
createdAt: now.subtract(const Duration(days: 30)),
updatedAt: now,
lastLoginAt: now,
);
}
} catch (e) {
// Failed to check session, but don't prevent app from starting
print('Failed to check saved session: $e');
}
return null;
}
/// Login with phone number
///
/// Uses Frappe ERPNext API authentication flow:
/// 1. Get current session from storage (should exist from app startup)
/// 2. Call login API with phone number
/// 3. Get new authenticated session with user credentials
/// 4. Update FlutterSecureStorage with new session
/// 5. Update Dio interceptors with new session for subsequent API calls
/// 6. Save rememberMe preference if enabled
///
/// Parameters:
/// - [phoneNumber]: User's phone number (Vietnamese format)
/// - [password]: User's password (reserved for future use, not sent yet)
/// - [rememberMe]: If true, session will be restored on next app launch
///
/// Returns: Authenticated User object on success
///
/// Throws: Exception on authentication failure
Future<void> login({
required String phoneNumber,
required String password,
bool rememberMe = false,
}) async {
// Set loading state
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
// Validation
if (phoneNumber.isEmpty) {
throw Exception('Số điện thoại không được để trống');
}
final frappeService = await _frappeAuthService;
final remoteDataSource = await _remoteDataSource;
// Get current session (should exist from app startup)
final currentSession = await frappeService.getStoredSession();
if (currentSession == null) {
// If no session, get a new one
await frappeService.getSession();
final newSession = await frappeService.getStoredSession();
if (newSession == null) {
throw Exception('Failed to get session');
}
}
// Get stored session again
final session = await frappeService.getStoredSession();
if (session == null) {
throw Exception('Session not available');
}
// Call login API with current session
final loginResponse = await remoteDataSource.login(
phone: phoneNumber,
csrfToken: session['csrfToken']!,
sid: session['sid']!,
password: password, // Reserved for future use
);
// Update FlutterSecureStorage with new authenticated session
await frappeService.login(phoneNumber, password: password);
// Save rememberMe preference
await _localDataSource.saveRememberMe(rememberMe);
// Create and return User entity
final now = DateTime.now();
return User(
userId: 'user_saved', // TODO: Get from API
phoneNumber: '', // TODO: Get from saved user data
fullName: session.fullName,
email: '', // TODO: Get from saved user data
userId: phoneNumber,
phoneNumber: phoneNumber,
fullName: loginResponse.fullName,
email: '',
role: UserRole.customer,
status: UserStatus.active,
loyaltyTier: LoyaltyTier.gold,
@@ -81,94 +223,6 @@ class Auth extends _$Auth {
referralCode: null,
referredBy: null,
erpnextCustomerId: null,
createdAt: session.createdAt,
updatedAt: now,
lastLoginAt: now,
);
}
return null;
}
/// Login with phone number and password
///
/// Simulates ERPNext API authentication with mock response.
/// Stores session data (SID, CSRF token) in Hive.
///
/// Parameters:
/// - [phoneNumber]: User's phone number (Vietnamese format)
/// - [password]: User's password
///
/// Returns: Authenticated User object on success
///
/// Throws: Exception on authentication failure
Future<void> login({
required String phoneNumber,
required String password,
}) async {
// Set loading state
state = const AsyncValue.loading();
// Simulate API call delay
state = await AsyncValue.guard(() async {
await Future<void>.delayed(const Duration(seconds: 2));
// Mock validation
if (phoneNumber.isEmpty || password.isEmpty) {
throw Exception('Số điện thoại và mật khẩu không được để trống');
}
if (password.length < 6) {
throw Exception('Mật khẩu phải có ít nhất 6 ký tự');
}
// Simulate API response matching ERPNext format
final mockApiResponse = AuthSessionResponse(
sessionExpired: 1,
message: const LoginMessage(
success: true,
message: 'Login successful',
sid: 'df7fd4e7ef1041aa3422b0ee861315ba8c28d4fe008a7d7e0e7e0e01',
csrfToken: '6b6e37563854e951c36a7af4177956bb15ca469ca4f498b742648d70',
apps: [
AppInfo(
appTitle: 'App nhân viên kinh doanh',
appEndpoint: '/ecommerce/app-sales',
appLogo:
'https://assets.digitalbiz.com.vn/DBIZ_Internal/Logo/logo_app_sales.png',
),
],
),
homePage: '/apps',
fullName: 'Tân Duy Nguyễn',
);
// Save session data to Hive
final sessionData = SessionData.fromAuthResponse(mockApiResponse);
await _localDataSource.saveSession(sessionData);
// Create and return User entity
final now = DateTime.now();
return User(
userId: 'user_${phoneNumber.replaceAll('+84', '')}',
phoneNumber: phoneNumber,
fullName: mockApiResponse.fullName,
email: 'user@eurotile.vn',
role: UserRole.customer,
status: UserStatus.active,
loyaltyTier: LoyaltyTier.gold,
totalPoints: 1500,
companyInfo: const CompanyInfo(
name: 'Công ty TNHH XYZ',
taxId: '0123456789',
businessType: 'Xây dựng',
),
cccd: '001234567890',
attachments: [],
address: '123 Đường ABC, Quận 1, TP.HCM',
avatarUrl: null,
referralCode: 'REF${phoneNumber.replaceAll('+84', '').substring(0, 6)}',
referredBy: null,
erpnextCustomerId: null,
createdAt: now.subtract(const Duration(days: 30)),
updatedAt: now,
lastLoginAt: now,
@@ -178,17 +232,20 @@ class Auth extends _$Auth {
/// Logout current user
///
/// Clears authentication state and removes saved session from Hive.
/// Clears authentication state and removes saved session.
/// Gets a new public session for registration/login.
Future<void> logout() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
// Clear saved session from Hive
final frappeService = await _frappeAuthService;
// Clear saved session
await _localDataSource.clearSession();
await frappeService.clearSession();
// TODO: Call logout API to invalidate token on server
await Future<void>.delayed(const Duration(milliseconds: 500));
// Get new public session for registration/login
await frappeService.getSession();
// Return null to indicate logged out
return null;
@@ -277,3 +334,31 @@ int userTotalPoints(Ref ref) {
final user = ref.watch(currentUserProvider);
return user?.totalPoints ?? 0;
}
/// Initialize Frappe session
///
/// Call this to ensure a Frappe session exists before making API calls.
/// This is separate from the Auth provider to avoid disposal issues.
///
/// Usage:
/// ```dart
/// // On login page or before API calls that need session
/// await ref.read(initializeFrappeSessionProvider.future);
/// ```
@riverpod
Future<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');
}
}

View File

@@ -113,6 +113,99 @@ final class AuthLocalDataSourceProvider
String _$authLocalDataSourceHash() =>
r'f104de00a8ab431f6736387fb499c2b6e0ab4924';
/// Provide FrappeAuthService instance
@ProviderFor(frappeAuthService)
const frappeAuthServiceProvider = FrappeAuthServiceProvider._();
/// Provide FrappeAuthService instance
final class FrappeAuthServiceProvider
extends
$FunctionalProvider<
AsyncValue<FrappeAuthService>,
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
///
/// Main provider for authentication state management.
@@ -179,7 +272,7 @@ final class AuthProvider extends $AsyncNotifierProvider<Auth, User?> {
Auth create() => Auth();
}
String _$authHash() => r'6f410d1abe6c53a6cbfa52fde7ea7a2d22a7f78d';
String _$authHash() => r'f1a16022d628a21f230c0bb567e80ff6e293d840';
/// Authentication Provider
///
@@ -498,3 +591,69 @@ final class UserTotalPointsProvider extends $FunctionalProvider<int, int, int>
}
String _$userTotalPointsHash() => r'9ccebb48a8641c3c0624b1649303b436e82602bd';
/// 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);
/// ```
@ProviderFor(initializeFrappeSession)
const initializeFrappeSessionProvider = InitializeFrappeSessionProvider._();
/// 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);
/// ```
final class InitializeFrappeSessionProvider
extends $FunctionalProvider<AsyncValue<void>, void, FutureOr<void>>
with $FutureModifier<void>, $FutureProvider<void> {
/// 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);
/// ```
const InitializeFrappeSessionProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'initializeFrappeSessionProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$initializeFrappeSessionHash();
@$internal
@override
$FutureProviderElement<void> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<void> create(Ref ref) {
return initializeFrappeSession(ref);
}
}
String _$initializeFrappeSessionHash() =>
r'1a9001246a39396e4712efc2cbeb0cac8b911f0c';

View File

@@ -0,0 +1,263 @@
/// 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';
import 'package:worker/features/news/data/models/blog_post_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');
}
}
/// Get blog posts
///
/// Fetches all published blog posts from Frappe.
/// Returns a list of [BlogPostModel].
///
/// API endpoint: POST https://land.dbiz.com/api/method/frappe.client.get_list
/// Request body:
/// ```json
/// {
/// "doctype": "Blog Post",
/// "fields": ["name","title","published_on","blogger","blog_intro","content","meta_image","meta_description","blog_category"],
/// "filters": {"published":1},
/// "order_by": "published_on desc",
/// "limit_page_length": 0
/// }
/// ```
///
/// Response format:
/// ```json
/// {
/// "message": [
/// {
/// "name": "post-slug",
/// "title": "Post Title",
/// "published_on": "2024-01-01 10:00:00",
/// "blogger": "Author Name",
/// "blog_intro": "Short introduction...",
/// "content": "<p>Full HTML content...</p>",
/// "meta_image": "https://...",
/// "meta_description": "SEO description",
/// "blog_category": "tin-tức"
/// },
/// ...
/// ]
/// }
/// ```
Future<List<BlogPostModel>> getBlogPosts() async {
try {
// Get Frappe session headers
final headers = await _frappeAuthService.getHeaders();
// Build full API URL
const url =
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}';
final response = await _dioClient.post<Map<String, dynamic>>(
url,
data: {
'doctype': 'Blog Post',
'fields': [
'name',
'title',
'published_on',
'blogger',
'blog_intro',
'content',
'meta_image',
'meta_description',
'blog_category',
],
'filters': {'published': 1},
'order_by': 'published_on 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 postsResponse = BlogPostsResponse.fromJson(response.data!);
return postsResponse.message;
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception('Blog posts endpoint not found');
} else if (e.response?.statusCode == 500) {
throw Exception('Server error while fetching blog posts');
} else if (e.type == DioExceptionType.connectionTimeout) {
throw Exception('Connection timeout while fetching blog posts');
} else if (e.type == DioExceptionType.receiveTimeout) {
throw Exception('Response timeout while fetching blog posts');
} else {
throw Exception('Failed to fetch blog posts: ${e.message}');
}
} catch (e) {
throw Exception('Unexpected error fetching blog posts: $e');
}
}
/// Get blog post detail by name
///
/// Fetches a single blog post by its unique name (slug) from Frappe.
/// Returns a [BlogPostModel].
///
/// API endpoint: POST https://land.dbiz.com/api/method/frappe.client.get
/// Request body:
/// ```json
/// {
/// "doctype": "Blog Post",
/// "name": "post-slug"
/// }
/// ```
///
/// Response format:
/// ```json
/// {
/// "message": {
/// "name": "post-slug",
/// "title": "Post Title",
/// "published_on": "2024-01-01 10:00:00",
/// "blogger": "Author Name",
/// "blog_intro": "Short introduction...",
/// "content": "<p>Full HTML content...</p>",
/// "meta_image": "https://...",
/// "meta_description": "SEO description",
/// "blog_category": "tin-tức"
/// }
/// }
/// ```
Future<BlogPostModel> getBlogPostDetail(String postName) async {
try {
// Get Frappe session headers
final headers = await _frappeAuthService.getHeaders();
// Build full API URL for frappe.client.get
const url =
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGet}';
final response = await _dioClient.post<Map<String, dynamic>>(
url,
data: {
'doctype': 'Blog Post',
'name': postName,
},
options: Options(headers: headers),
);
if (response.data == null) {
throw Exception('Empty response from server');
}
// The response has the blog post data directly in "message" field
final messageData = response.data!['message'];
if (messageData == null) {
throw Exception('Blog post not found: $postName');
}
// Parse the blog post from the message field
return BlogPostModel.fromJson(messageData as Map<String, dynamic>);
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception('Blog post not found: $postName');
} else if (e.response?.statusCode == 500) {
throw Exception('Server error while fetching blog post detail');
} else if (e.type == DioExceptionType.connectionTimeout) {
throw Exception('Connection timeout while fetching blog post detail');
} else if (e.type == DioExceptionType.receiveTimeout) {
throw Exception('Response timeout while fetching blog post detail');
} else {
throw Exception('Failed to fetch blog post detail: ${e.message}');
}
} catch (e) {
throw Exception('Unexpected error fetching blog post detail: $e');
}
}
}

View 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(),
};
}
}

View 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};

View File

@@ -0,0 +1,179 @@
/// Blog Post Model for Frappe API
///
/// Maps to Frappe Blog Post doctype from blog.sh API.
library;
import 'package:json_annotation/json_annotation.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
part 'blog_post_model.g.dart';
/// Blog Post Model
///
/// Represents a blog post from Frappe ERPNext system.
/// Fields match the API response from frappe.client.get_list for Blog Post doctype.
@JsonSerializable()
class BlogPostModel {
/// Unique post identifier (docname)
final String name;
/// Post title
final String title;
/// Publication date (ISO 8601 string)
@JsonKey(name: 'published_on')
final String? publishedOn;
/// Author/blogger name
final String? blogger;
/// Short introduction/excerpt
@JsonKey(name: 'blog_intro')
final String? blogIntro;
/// Full HTML content (legacy field)
final String? content;
/// Full HTML content (new field from Frappe)
@JsonKey(name: 'content_html')
final String? contentHtml;
/// Featured/meta image URL
@JsonKey(name: 'meta_image')
final String? metaImage;
/// Meta description for SEO
@JsonKey(name: 'meta_description')
final String? metaDescription;
/// Blog category name
@JsonKey(name: 'blog_category')
final String? blogCategory;
/// Constructor
const BlogPostModel({
required this.name,
required this.title,
this.publishedOn,
this.blogger,
this.blogIntro,
this.content,
this.contentHtml,
this.metaImage,
this.metaDescription,
this.blogCategory,
});
/// Create model from JSON
factory BlogPostModel.fromJson(Map<String, dynamic> json) =>
_$BlogPostModelFromJson(json);
/// Convert model to JSON
Map<String, dynamic> toJson() => _$BlogPostModelToJson(this);
/// Convert to domain entity (NewsArticle)
///
/// Stores the original blog_category name in tags[0] for filtering purposes.
NewsArticle toEntity() {
// Parse published date
DateTime publishedDate = DateTime.now();
if (publishedOn != null) {
try {
publishedDate = DateTime.parse(publishedOn!);
} catch (e) {
// Use current date if parsing fails
publishedDate = DateTime.now();
}
}
// Extract excerpt from blogIntro or metaDescription
final excerpt = blogIntro ?? metaDescription ?? '';
// Use content_html preferentially, fall back to content
final htmlContent = contentHtml ?? content;
// Use meta image with full URL path
String imageUrl;
if (metaImage != null && metaImage!.isNotEmpty) {
// If meta_image starts with /, prepend the base URL
if (metaImage!.startsWith('/')) {
imageUrl = 'https://land.dbiz.com$metaImage';
} else if (metaImage!.startsWith('http')) {
imageUrl = metaImage!;
} else {
imageUrl = 'https://land.dbiz.com/$metaImage';
}
} else {
imageUrl = 'https://via.placeholder.com/400x300?text=${Uri.encodeComponent(title)}';
}
// Parse category
final category = _parseCategory(blogCategory ?? '');
// Calculate reading time (rough estimate: 200 words per minute)
final wordCount = (htmlContent ?? excerpt).split(' ').length;
final readingTime = (wordCount / 200).ceil().clamp(1, 60);
return NewsArticle(
id: name,
title: title,
excerpt: excerpt.length > 200 ? '${excerpt.substring(0, 200)}...' : excerpt,
content: htmlContent,
imageUrl: imageUrl,
category: category,
publishedDate: publishedDate,
viewCount: 0, // Not provided by API
readingTimeMinutes: readingTime,
isFeatured: false, // Not provided by API
authorName: blogger,
authorAvatar: null, // Not provided by API
tags: blogCategory != null ? [blogCategory!] : [], // Store original category name for filtering
likeCount: 0, // Not provided by API
commentCount: 0, // Not provided by API
shareCount: 0, // Not provided by API
);
}
/// Parse category from blog category name
static NewsCategory _parseCategory(String categoryName) {
final lower = categoryName.toLowerCase();
if (lower.contains('tin') || lower.contains('news')) {
return NewsCategory.news;
} else if (lower.contains('chuyên') ||
lower.contains('kỹ thuật') ||
lower.contains('professional') ||
lower.contains('technique')) {
return NewsCategory.professional;
} else if (lower.contains('dự án') ||
lower.contains('project') ||
lower.contains('công trình')) {
return NewsCategory.projects;
} else if (lower.contains('sự kiện') || lower.contains('event')) {
return NewsCategory.events;
} else if (lower.contains('khuyến mãi') ||
lower.contains('promotion') ||
lower.contains('ưu đãi')) {
return NewsCategory.promotions;
}
return NewsCategory.news;
}
@override
String toString() {
return 'BlogPostModel(name: $name, title: $title, category: $blogCategory, '
'publishedOn: $publishedOn, hasContentHtml: ${contentHtml != null})';
}
}
/// Response wrapper for Blog Post list API
@JsonSerializable()
class BlogPostsResponse {
final List<BlogPostModel> message;
const BlogPostsResponse({required this.message});
factory BlogPostsResponse.fromJson(Map<String, dynamic> json) =>
_$BlogPostsResponseFromJson(json);
Map<String, dynamic> toJson() => _$BlogPostsResponseToJson(this);
}

View File

@@ -0,0 +1,71 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'blog_post_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BlogPostModel _$BlogPostModelFromJson(Map<String, dynamic> json) =>
$checkedCreate(
'BlogPostModel',
json,
($checkedConvert) {
final val = BlogPostModel(
name: $checkedConvert('name', (v) => v as String),
title: $checkedConvert('title', (v) => v as String),
publishedOn: $checkedConvert('published_on', (v) => v as String?),
blogger: $checkedConvert('blogger', (v) => v as String?),
blogIntro: $checkedConvert('blog_intro', (v) => v as String?),
content: $checkedConvert('content', (v) => v as String?),
contentHtml: $checkedConvert('content_html', (v) => v as String?),
metaImage: $checkedConvert('meta_image', (v) => v as String?),
metaDescription: $checkedConvert(
'meta_description',
(v) => v as String?,
),
blogCategory: $checkedConvert('blog_category', (v) => v as String?),
);
return val;
},
fieldKeyMap: const {
'publishedOn': 'published_on',
'blogIntro': 'blog_intro',
'contentHtml': 'content_html',
'metaImage': 'meta_image',
'metaDescription': 'meta_description',
'blogCategory': 'blog_category',
},
);
Map<String, dynamic> _$BlogPostModelToJson(BlogPostModel instance) =>
<String, dynamic>{
'name': instance.name,
'title': instance.title,
'published_on': ?instance.publishedOn,
'blogger': ?instance.blogger,
'blog_intro': ?instance.blogIntro,
'content': ?instance.content,
'content_html': ?instance.contentHtml,
'meta_image': ?instance.metaImage,
'meta_description': ?instance.metaDescription,
'blog_category': ?instance.blogCategory,
};
BlogPostsResponse _$BlogPostsResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('BlogPostsResponse', json, ($checkedConvert) {
final val = BlogPostsResponse(
message: $checkedConvert(
'message',
(v) => (v as List<dynamic>)
.map((e) => BlogPostModel.fromJson(e as Map<String, dynamic>))
.toList(),
),
);
return val;
});
Map<String, dynamic> _$BlogPostsResponseToJson(BlogPostsResponse instance) =>
<String, dynamic>{
'message': instance.message.map((e) => e.toJson()).toList(),
};

View File

@@ -5,16 +5,40 @@
library;
import 'package:worker/features/news/data/datasources/news_local_datasource.dart';
import 'package:worker/features/news/data/datasources/news_remote_datasource.dart';
import 'package:worker/features/news/domain/entities/blog_category.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
import 'package:worker/features/news/domain/repositories/news_repository.dart';
/// News Repository Implementation
class NewsRepositoryImpl implements NewsRepository {
/// Constructor
NewsRepositoryImpl({
required this.localDataSource,
required this.remoteDataSource,
});
/// Local data source
final NewsLocalDataSource localDataSource;
/// Constructor
NewsRepositoryImpl({required this.localDataSource});
/// Remote data source
final NewsRemoteDataSource 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
Future<List<NewsArticle>> getAllArticles() async {
@@ -74,6 +98,20 @@ class NewsRepositoryImpl implements NewsRepository {
}
}
@override
Future<NewsArticle?> getArticleByIdFromApi(String articleId) async {
try {
// Fetch blog post detail from Frappe API using frappe.client.get
final blogPostModel = await remoteDataSource.getBlogPostDetail(articleId);
// Convert to domain entity
return blogPostModel.toEntity();
} catch (e) {
print('[NewsRepository] Error getting article by id from API: $e');
rethrow; // Re-throw to let providers handle the error
}
}
@override
Future<List<NewsArticle>> refreshArticles() async {
try {

View 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)';
}
}

View File

@@ -4,12 +4,16 @@
/// This is an abstract interface following the Repository Pattern.
library;
import 'package:worker/features/news/domain/entities/blog_category.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
/// News Repository Interface
///
/// Provides methods to fetch and manage news articles.
/// Provides methods to fetch and manage news articles and categories.
abstract class NewsRepository {
/// Get all blog categories from Frappe API
Future<List<BlogCategory>> getBlogCategories();
/// Get all news articles
Future<List<NewsArticle>> getAllArticles();
@@ -19,9 +23,13 @@ abstract class NewsRepository {
/// Get articles by category
Future<List<NewsArticle>> getArticlesByCategory(NewsCategory category);
/// Get a specific article by ID
/// Get a specific article by ID (from local cache)
Future<NewsArticle?> getArticleById(String articleId);
/// Get a specific article by ID from API
/// Uses frappe.client.get endpoint to fetch the full blog post detail
Future<NewsArticle?> getArticleByIdFromApi(String articleId);
/// Refresh articles from server
Future<List<NewsArticle>> refreshArticles();
}

View File

@@ -735,16 +735,3 @@ class _NewsDetailPageState extends ConsumerState<NewsDetailPage> {
// );
}
}
/// Provider for getting article by ID
final newsArticleByIdProvider = FutureProvider.family<NewsArticle?, String>((
ref,
id,
) async {
final articles = await ref.watch(newsArticlesProvider.future);
try {
return articles.firstWhere((article) => article.id == id);
} catch (e) {
return null;
}
});

View File

@@ -19,20 +19,36 @@ import 'package:worker/features/news/presentation/widgets/news_card.dart';
///
/// Features:
/// - Standard AppBar with title "Tin tức & chuyên môn"
/// - Horizontal scrollable category chips (Tất cả, Tin tức, Chuyên môn, Dự án, Sự kiện, Khuyến mãi)
/// - Horizontal scrollable category chips (dynamic from Frappe API)
/// - Featured article section (large card)
/// - "Mới nhất" section with news cards list
/// - RefreshIndicator for pull-to-refresh
/// - Loading and error states
class NewsListPage extends ConsumerWidget {
class NewsListPage extends ConsumerStatefulWidget {
const NewsListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<NewsListPage> createState() => _NewsListPageState();
}
class _NewsListPageState extends ConsumerState<NewsListPage> {
/// Currently selected category name (null = All)
String? selectedCategoryName;
@override
Widget build(BuildContext context) {
// Watch providers
final featuredArticleAsync = ref.watch(featuredArticleProvider);
final filteredArticlesAsync = ref.watch(filteredNewsArticlesProvider);
final selectedCategory = ref.watch(selectedNewsCategoryProvider);
final newsArticlesAsync = ref.watch(newsArticlesProvider);
// Filter articles by selected category
final filteredArticles = newsArticlesAsync.whenData((articles) {
if (selectedCategoryName == null) {
return articles;
}
// TODO: Filter by category when articles have category field
return articles;
});
return Scaffold(
backgroundColor: Colors.white,
@@ -40,9 +56,10 @@ class NewsListPage extends ConsumerWidget {
body: RefreshIndicator(
onRefresh: () async {
// Invalidate providers to trigger refresh
ref.invalidate(newsArticlesProvider);
ref.invalidate(featuredArticleProvider);
ref.invalidate(filteredNewsArticlesProvider);
ref
..invalidate(newsArticlesProvider)
..invalidate(featuredArticleProvider)
..invalidate(blogCategoriesProvider);
},
child: CustomScrollView(
slivers: [
@@ -51,11 +68,11 @@ class NewsListPage extends ConsumerWidget {
child: Padding(
padding: const EdgeInsets.only(top: 4, bottom: AppSpacing.md),
child: CategoryFilterChips(
selectedCategory: selectedCategory,
onCategorySelected: (category) {
ref
.read(selectedNewsCategoryProvider.notifier)
.setCategory(category);
selectedCategoryName: selectedCategoryName,
onCategorySelected: (categoryName) {
setState(() {
selectedCategoryName = categoryName;
});
},
),
),
@@ -69,44 +86,9 @@ class NewsListPage extends ConsumerWidget {
}
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section title "Nổi bật"
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
),
child: Row(
children: [
Icon(
Icons.star,
size: 18,
color: AppColors.primaryBlue,
),
const SizedBox(width: 8),
const Text(
'Nổi bật',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
],
),
),
const SizedBox(height: AppSpacing.md),
// Featured card
FeaturedNewsCard(
article: article,
onTap: () => _onArticleTap(context, article),
),
const SizedBox(height: 32),
],
child: FeaturedNewsCard(
article: article,
onTap: () => _onArticleTap(context, article),
),
);
},
@@ -120,10 +102,13 @@ class NewsListPage extends ConsumerWidget {
const SliverToBoxAdapter(child: SizedBox.shrink()),
),
const SliverToBoxAdapter(
child: SizedBox(height: AppSpacing.xl),
),
// Latest News Section
SliverToBoxAdapter(
const SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Row(
children: [
Icon(
@@ -131,8 +116,8 @@ class NewsListPage extends ConsumerWidget {
size: 18,
color: AppColors.primaryBlue,
),
const SizedBox(width: 8),
const Text(
SizedBox(width: 8),
Text(
'Mới nhất',
style: TextStyle(
fontSize: 18,
@@ -148,7 +133,7 @@ class NewsListPage extends ConsumerWidget {
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
// News List
filteredArticlesAsync.when(
filteredArticles.when(
data: (articles) {
if (articles.isEmpty) {
return SliverFillRemaining(child: _buildEmptyState());

View File

@@ -5,10 +5,14 @@
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/network/dio_client.dart';
import 'package:worker/core/services/frappe_auth_provider.dart';
import 'package:worker/features/news/data/datasources/news_local_datasource.dart';
import 'package:worker/features/news/data/repositories/news_repository_impl.dart';
import 'package:worker/features/news/data/datasources/news_remote_datasource.dart';
import 'package:worker/features/news/domain/entities/blog_category.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
import 'package:worker/features/news/domain/repositories/news_repository.dart';
import 'package:worker/features/news/data/repositories/news_repository_impl.dart';
part 'news_provider.g.dart';
@@ -20,36 +24,75 @@ NewsLocalDataSource newsLocalDataSource(Ref ref) {
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
///
/// Provides instance of NewsRepository implementation.
@riverpod
NewsRepository newsRepository(Ref ref) {
Future<NewsRepository> newsRepository(Ref ref) async {
final localDataSource = ref.watch(newsLocalDataSourceProvider);
return NewsRepositoryImpl(localDataSource: localDataSource);
final remoteDataSource = await ref.watch(newsRemoteDataSourceProvider.future);
return NewsRepositoryImpl(
localDataSource: localDataSource,
remoteDataSource: remoteDataSource,
);
}
/// News Articles Provider
/// All News Articles Provider (Internal)
///
/// Fetches all news articles sorted by published date.
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
/// Fetches ALL blog posts from Frappe API sorted by published date (latest first).
/// This is the complete list used by both featured and latest articles providers.
/// Do not use this provider directly in UI - use featuredArticle or newsArticles instead.
@riverpod
Future<List<NewsArticle>> newsArticles(Ref ref) async {
final repository = ref.watch(newsRepositoryProvider);
return repository.getAllArticles();
Future<List<NewsArticle>> _allNewsArticles(Ref ref) async {
final remoteDataSource = await ref.watch(newsRemoteDataSourceProvider.future);
// Fetch blog posts from Frappe API
final blogPosts = await remoteDataSource.getBlogPosts();
// Convert to NewsArticle entities
final articles = blogPosts.map((post) => post.toEntity()).toList();
// Already sorted by published_on desc from API
return articles;
}
/// Featured Article Provider
///
/// Fetches the featured article for the top section.
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
/// Returns the first article from the complete list.
/// This is the latest published article that will be displayed prominently at the top.
@riverpod
Future<NewsArticle?> featuredArticle(Ref ref) async {
final repository = ref.watch(newsRepositoryProvider);
return repository.getFeaturedArticle();
final allArticles = await ref.watch(_allNewsArticlesProvider.future);
// Return first article if available (latest post)
return allArticles.isNotEmpty ? allArticles.first : null;
}
/// Selected News Category Provider
/// News Articles Provider
///
/// Returns latest news articles EXCLUDING the first item (which is shown as featured).
/// This ensures each article only appears once on the page.
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
@riverpod
Future<List<NewsArticle>> newsArticles(Ref ref) async {
final allArticles = await ref.watch(_allNewsArticlesProvider.future);
// Return all articles except first (which is featured)
// If only 0-1 articles, return empty list
return allArticles.length > 1 ? allArticles.sublist(1) : [];
}
/// Selected News Category Provider (Legacy - using enum)
///
/// Manages the currently selected category filter.
/// null means "All" is selected (show all categories).
@@ -72,30 +115,81 @@ class SelectedNewsCategory extends _$SelectedNewsCategory {
}
}
/// Filtered News Articles Provider
/// Selected Category Name Provider
///
/// Returns news articles filtered by selected category.
/// If no category is selected, returns all articles.
/// Manages the currently selected blog category name (from Frappe API).
/// null means "All" is selected (show all categories).
///
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
@riverpod
Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
final selectedCategory = ref.watch(selectedNewsCategoryProvider);
final repository = ref.watch(newsRepositoryProvider);
// If no category selected, return all articles
if (selectedCategory == null) {
return repository.getAllArticles();
class SelectedCategoryName extends _$SelectedCategoryName {
@override
String? build() {
// Default: show all categories
return null;
}
// Filter by selected category
return repository.getArticlesByCategory(selectedCategory);
/// Set selected category by name
void setCategoryName(String? categoryName) {
state = categoryName;
}
/// Clear selection (show all)
void clearSelection() {
state = null;
}
}
/// Filtered News Articles Provider
///
/// Returns news articles filtered by selected blog category name.
/// Excludes the first article (which is shown as featured).
/// If no category is selected, returns all articles except first.
///
/// The blog_category name from API is stored in article.tags[0] for filtering.
@riverpod
Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
final selectedCategoryName = ref.watch(selectedCategoryNameProvider);
final allArticles = await ref.watch(_allNewsArticlesProvider.future);
// Get articles excluding first (which is featured)
final articlesWithoutFeatured = allArticles.length > 1 ? allArticles.sublist(1) : <NewsArticle>[];
// If no category selected, return all articles except first
if (selectedCategoryName == null) {
return articlesWithoutFeatured;
}
// Filter articles by blog_category name (stored in tags[0])
return articlesWithoutFeatured.where((article) {
// Check if article has tags and first tag matches selected category
return article.tags.isNotEmpty && article.tags[0] == selectedCategoryName;
}).toList();
}
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Fetches a specific article by ID from the Frappe API.
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
/// Used for article detail page.
@riverpod
Future<NewsArticle?> newsArticleById(Ref ref, String articleId) async {
final repository = ref.watch(newsRepositoryProvider);
return repository.getArticleById(articleId);
final repository = await ref.watch(newsRepositoryProvider.future);
return repository.getArticleByIdFromApi(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();
}

View File

@@ -67,6 +67,59 @@ final class NewsLocalDataSourceProvider
String _$newsLocalDataSourceHash() =>
r'e7e7d71d20274fe8b498c7b15f8aeb9eb515af27';
/// News Remote DataSource Provider
///
/// Provides instance of NewsRemoteDataSource with Frappe auth service.
@ProviderFor(newsRemoteDataSource)
const newsRemoteDataSourceProvider = NewsRemoteDataSourceProvider._();
/// News Remote DataSource Provider
///
/// Provides instance of NewsRemoteDataSource with Frappe auth service.
final class NewsRemoteDataSourceProvider
extends
$FunctionalProvider<
AsyncValue<NewsRemoteDataSource>,
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
///
/// Provides instance of NewsRepository implementation.
@@ -79,8 +132,13 @@ const newsRepositoryProvider = NewsRepositoryProvider._();
/// Provides instance of NewsRepository implementation.
final class NewsRepositoryProvider
extends $FunctionalProvider<NewsRepository, NewsRepository, NewsRepository>
with $Provider<NewsRepository> {
extends
$FunctionalProvider<
AsyncValue<NewsRepository>,
NewsRepository,
FutureOr<NewsRepository>
>
with $FutureModifier<NewsRepository>, $FutureProvider<NewsRepository> {
/// News Repository Provider
///
/// Provides instance of NewsRepository implementation.
@@ -100,28 +158,133 @@ final class NewsRepositoryProvider
@$internal
@override
$ProviderElement<NewsRepository> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
$FutureProviderElement<NewsRepository> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
NewsRepository create(Ref ref) {
FutureOr<NewsRepository> create(Ref 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';
/// All News Articles Provider (Internal)
///
/// Fetches ALL blog posts from Frappe API sorted by published date (latest first).
/// This is the complete list used by both featured and latest articles providers.
/// Do not use this provider directly in UI - use featuredArticle or newsArticles instead.
@ProviderFor(_allNewsArticles)
const _allNewsArticlesProvider = _AllNewsArticlesProvider._();
/// All News Articles Provider (Internal)
///
/// Fetches ALL blog posts from Frappe API sorted by published date (latest first).
/// This is the complete list used by both featured and latest articles providers.
/// Do not use this provider directly in UI - use featuredArticle or newsArticles instead.
final class _AllNewsArticlesProvider
extends
$FunctionalProvider<
AsyncValue<List<NewsArticle>>,
List<NewsArticle>,
FutureOr<List<NewsArticle>>
>
with
$FutureModifier<List<NewsArticle>>,
$FutureProvider<List<NewsArticle>> {
/// All News Articles Provider (Internal)
///
/// Fetches ALL blog posts from Frappe API sorted by published date (latest first).
/// This is the complete list used by both featured and latest articles providers.
/// Do not use this provider directly in UI - use featuredArticle or newsArticles instead.
const _AllNewsArticlesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'_allNewsArticlesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$_allNewsArticlesHash();
@$internal
@override
$FutureProviderElement<List<NewsArticle>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<NewsArticle>> create(Ref ref) {
return _allNewsArticles(ref);
}
}
String _$_allNewsArticlesHash() => r'9ee5c1449f1a72710e801a6b4a9e5c72df842e61';
/// Featured Article Provider
///
/// Returns the first article from the complete list.
/// This is the latest published article that will be displayed prominently at the top.
@ProviderFor(featuredArticle)
const featuredArticleProvider = FeaturedArticleProvider._();
/// Featured Article Provider
///
/// Returns the first article from the complete list.
/// This is the latest published article that will be displayed prominently at the top.
final class FeaturedArticleProvider
extends
$FunctionalProvider<
AsyncValue<NewsArticle?>,
NewsArticle?,
FutureOr<NewsArticle?>
>
with $FutureModifier<NewsArticle?>, $FutureProvider<NewsArticle?> {
/// Featured Article Provider
///
/// Returns the first article from the complete list.
/// This is the latest published article that will be displayed prominently at the top.
const FeaturedArticleProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'featuredArticleProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$featuredArticleHash();
@$internal
@override
$FutureProviderElement<NewsArticle?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<NewsArticle?> create(Ref ref) {
return featuredArticle(ref);
}
}
String _$featuredArticleHash() => r'046567d4385aca2abe10767a98744c2c1cfafd78';
/// News Articles Provider
///
/// Fetches all news articles sorted by published date.
/// Returns latest news articles EXCLUDING the first item (which is shown as featured).
/// This ensures each article only appears once on the page.
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
@ProviderFor(newsArticles)
@@ -129,7 +292,8 @@ const newsArticlesProvider = NewsArticlesProvider._();
/// News Articles Provider
///
/// Fetches all news articles sorted by published date.
/// Returns latest news articles EXCLUDING the first item (which is shown as featured).
/// This ensures each article only appears once on the page.
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
final class NewsArticlesProvider
@@ -144,7 +308,8 @@ final class NewsArticlesProvider
$FutureProvider<List<NewsArticle>> {
/// News Articles Provider
///
/// Fetches all news articles sorted by published date.
/// Returns latest news articles EXCLUDING the first item (which is shown as featured).
/// This ensures each article only appears once on the page.
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
const NewsArticlesProvider._()
: super(
@@ -172,62 +337,9 @@ final class NewsArticlesProvider
}
}
String _$newsArticlesHash() => r'24d70e49f7137c614c024dc93c97451c6e161ce6';
String _$newsArticlesHash() => r'954f28885540368a095a3423f4f64c0f1ff0f47d';
/// Featured Article Provider
///
/// Fetches the featured article for the top section.
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
@ProviderFor(featuredArticle)
const featuredArticleProvider = FeaturedArticleProvider._();
/// Featured Article Provider
///
/// Fetches the featured article for the top section.
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
final class FeaturedArticleProvider
extends
$FunctionalProvider<
AsyncValue<NewsArticle?>,
NewsArticle?,
FutureOr<NewsArticle?>
>
with $FutureModifier<NewsArticle?>, $FutureProvider<NewsArticle?> {
/// Featured Article Provider
///
/// Fetches the featured article for the top section.
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
const FeaturedArticleProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'featuredArticleProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$featuredArticleHash();
@$internal
@override
$FutureProviderElement<NewsArticle?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<NewsArticle?> create(Ref ref) {
return featuredArticle(ref);
}
}
String _$featuredArticleHash() => r'f7146600bc3bbaf5987ab6b09262135b1558f1c0';
/// Selected News Category Provider
/// Selected News Category Provider (Legacy - using enum)
///
/// Manages the currently selected category filter.
/// null means "All" is selected (show all categories).
@@ -235,13 +347,13 @@ String _$featuredArticleHash() => r'f7146600bc3bbaf5987ab6b09262135b1558f1c0';
@ProviderFor(SelectedNewsCategory)
const selectedNewsCategoryProvider = SelectedNewsCategoryProvider._();
/// Selected News Category Provider
/// Selected News Category Provider (Legacy - using enum)
///
/// Manages the currently selected category filter.
/// null means "All" is selected (show all categories).
final class SelectedNewsCategoryProvider
extends $NotifierProvider<SelectedNewsCategory, NewsCategory?> {
/// Selected News Category Provider
/// Selected News Category Provider (Legacy - using enum)
///
/// Manages the currently selected category filter.
/// null means "All" is selected (show all categories).
@@ -275,7 +387,7 @@ final class SelectedNewsCategoryProvider
String _$selectedNewsCategoryHash() =>
r'f1dca9a5d7de94cac90494d94ce05b727e6e4d5f';
/// Selected News Category Provider
/// Selected News Category Provider (Legacy - using enum)
///
/// Manages the currently selected category filter.
/// null means "All" is selected (show all categories).
@@ -299,18 +411,104 @@ abstract class _$SelectedNewsCategory extends $Notifier<NewsCategory?> {
}
}
/// Selected Category Name Provider
///
/// Manages the currently selected blog category name (from Frappe API).
/// null means "All" is selected (show all categories).
///
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
@ProviderFor(SelectedCategoryName)
const selectedCategoryNameProvider = SelectedCategoryNameProvider._();
/// Selected Category Name Provider
///
/// Manages the currently selected blog category name (from Frappe API).
/// null means "All" is selected (show all categories).
///
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
final class SelectedCategoryNameProvider
extends $NotifierProvider<SelectedCategoryName, String?> {
/// Selected Category Name Provider
///
/// Manages the currently selected blog category name (from Frappe API).
/// null means "All" is selected (show all categories).
///
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
const SelectedCategoryNameProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedCategoryNameProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedCategoryNameHash();
@$internal
@override
SelectedCategoryName create() => SelectedCategoryName();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$selectedCategoryNameHash() =>
r'8dfbf490b986275e6ed9d7b423ae16f074c7fa36';
/// Selected Category Name Provider
///
/// Manages the currently selected blog category name (from Frappe API).
/// null means "All" is selected (show all categories).
///
/// Examples: "tin-tức", "dự-án", "chuyên-môn", "khuyến-mãi"
abstract class _$SelectedCategoryName extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Filtered News Articles Provider
///
/// Returns news articles filtered by selected category.
/// If no category is selected, returns all articles.
/// Returns news articles filtered by selected blog category name.
/// Excludes the first article (which is shown as featured).
/// If no category is selected, returns all articles except first.
///
/// The blog_category name from API is stored in article.tags[0] for filtering.
@ProviderFor(filteredNewsArticles)
const filteredNewsArticlesProvider = FilteredNewsArticlesProvider._();
/// Filtered News Articles Provider
///
/// Returns news articles filtered by selected category.
/// If no category is selected, returns all articles.
/// Returns news articles filtered by selected blog category name.
/// Excludes the first article (which is shown as featured).
/// If no category is selected, returns all articles except first.
///
/// The blog_category name from API is stored in article.tags[0] for filtering.
final class FilteredNewsArticlesProvider
extends
@@ -324,8 +522,11 @@ final class FilteredNewsArticlesProvider
$FutureProvider<List<NewsArticle>> {
/// Filtered News Articles Provider
///
/// Returns news articles filtered by selected category.
/// If no category is selected, returns all articles.
/// Returns news articles filtered by selected blog category name.
/// Excludes the first article (which is shown as featured).
/// If no category is selected, returns all articles except first.
///
/// The blog_category name from API is stored in article.tags[0] for filtering.
const FilteredNewsArticlesProvider._()
: super(
from: null,
@@ -353,11 +554,12 @@ final class FilteredNewsArticlesProvider
}
String _$filteredNewsArticlesHash() =>
r'f40a737b74b44f2d4fa86977175314ed0da471fa';
r'52b823eabce0acfbef33cc85b5f31f3e9588df4f';
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Fetches a specific article by ID from the Frappe API.
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
/// Used for article detail page.
@ProviderFor(newsArticleById)
@@ -365,7 +567,8 @@ const newsArticleByIdProvider = NewsArticleByIdFamily._();
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Fetches a specific article by ID from the Frappe API.
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
/// Used for article detail page.
final class NewsArticleByIdProvider
@@ -378,7 +581,8 @@ final class NewsArticleByIdProvider
with $FutureModifier<NewsArticle?>, $FutureProvider<NewsArticle?> {
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Fetches a specific article by ID from the Frappe API.
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
/// Used for article detail page.
const NewsArticleByIdProvider._({
required NewsArticleByIdFamily super.from,
@@ -424,11 +628,12 @@ final class NewsArticleByIdProvider
}
}
String _$newsArticleByIdHash() => r'4d28caa81d486fcd6cfefd16477355927bbcadc8';
String _$newsArticleByIdHash() => r'83e4790f0ebb80da5f0385f489ed2221fe769e3c';
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Fetches a specific article by ID from the Frappe API.
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
/// Used for article detail page.
final class NewsArticleByIdFamily extends $Family
@@ -444,7 +649,8 @@ final class NewsArticleByIdFamily extends $Family
/// News Article by ID Provider
///
/// Fetches a specific article by ID.
/// Fetches a specific article by ID from the Frappe API.
/// Uses frappe.client.get endpoint to fetch the full blog post detail.
/// Used for article detail page.
NewsArticleByIdProvider call(String articleId) =>
@@ -453,3 +659,76 @@ final class NewsArticleByIdFamily extends $Family
@override
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';

View File

@@ -2,37 +2,53 @@
///
/// Horizontal scrollable list of category filter chips.
/// Used in news list page for filtering articles by category.
/// Fetches categories dynamically from the Frappe API.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/news/domain/entities/news_article.dart';
import 'package:worker/features/news/domain/entities/blog_category.dart';
import 'package:worker/features/news/presentation/providers/news_provider.dart';
/// Category Filter Chips
///
/// Displays a horizontal scrollable row of filter chips for news categories.
/// Features:
/// - "Tất cả" (All) option to show all categories
/// - 5 category options: Tin tức, Chuyên môn, Dự án, Sự kiện, Khuyến mãi
/// - Dynamic categories from Frappe API (Tin tức, Chuyên môn, Dự án, Khuyến mãi)
/// - Active state styling (primary blue background, white text)
/// - Inactive state styling (grey background, grey text)
class CategoryFilterChips extends StatelessWidget {
/// Currently selected category (null = All)
final NewsCategory? selectedCategory;
/// - Loading state with shimmer effect
/// - Error state with retry button
class CategoryFilterChips extends ConsumerWidget {
/// Currently selected category name (null = All)
final String? selectedCategoryName;
/// Callback when a category is tapped
final void Function(NewsCategory? category) onCategorySelected;
/// Callback when a category is tapped (passes category name)
final void Function(String? categoryName) onCategorySelected;
/// Constructor
const CategoryFilterChips({
super.key,
required this.selectedCategory,
required this.selectedCategoryName,
required this.onCategorySelected,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final categoriesAsync = ref.watch(blogCategoriesProvider);
return categoriesAsync.when(
data: (categories) => _buildCategoryChips(categories),
loading: () => _buildLoadingState(),
error: (error, stack) => _buildErrorState(error, ref),
);
}
/// Build category chips with data
Widget _buildCategoryChips(List<BlogCategory> categories) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
@@ -41,20 +57,20 @@ class CategoryFilterChips extends StatelessWidget {
// "Tất cả" chip
_buildCategoryChip(
label: 'Tất cả',
isSelected: selectedCategory == null,
isSelected: selectedCategoryName == null,
onTap: () => onCategorySelected(null),
),
const SizedBox(width: AppSpacing.sm),
// Category chips
...NewsCategory.values.map((category) {
// Dynamic category chips from API
...categories.map((category) {
return Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: _buildCategoryChip(
label: category.displayName,
isSelected: selectedCategory == category,
onTap: () => onCategorySelected(category),
label: category.title,
isSelected: selectedCategoryName == category.name,
onTap: () => onCategorySelected(category.name),
),
);
}),
@@ -63,6 +79,70 @@ class CategoryFilterChips extends StatelessWidget {
);
}
/// Build loading state with shimmer placeholders
Widget _buildLoadingState() {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Row(
children: List.generate(5, (index) {
return Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: Container(
width: 80,
height: 32,
decoration: BoxDecoration(
color: AppColors.grey100,
borderRadius: BorderRadius.circular(24),
),
),
);
}),
),
);
}
/// Build error state with retry
Widget _buildErrorState(Object error, WidgetRef ref) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: AppColors.grey100,
borderRadius: BorderRadius.circular(24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 16, color: AppColors.grey500),
const SizedBox(width: AppSpacing.xs),
Text(
'Lỗi tải danh mục',
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
),
const SizedBox(width: AppSpacing.xs),
GestureDetector(
onTap: () => ref.refresh(blogCategoriesProvider),
child: Icon(Icons.refresh, size: 16, color: AppColors.primaryBlue),
),
],
),
),
],
),
);
}
/// Build individual category chip
Widget _buildCategoryChip({
required String label,

View File

@@ -20,15 +20,15 @@ import 'package:worker/features/news/domain/entities/news_article.dart';
/// - Category badge (primary blue)
/// - Shadow and rounded corners
class FeaturedNewsCard extends StatelessWidget {
/// Constructor
const FeaturedNewsCard({super.key, required this.article, this.onTap});
/// News article to display
final NewsArticle article;
/// Callback when card is tapped
final VoidCallback? onTap;
/// Constructor
const FeaturedNewsCard({super.key, required this.article, this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
@@ -126,17 +126,17 @@ class FeaturedNewsCard extends StatelessWidget {
text: article.formattedDate,
),
// Views
_buildMetaItem(
icon: Icons.visibility,
text: '${article.formattedViewCount} lượt xem',
),
// Reading time
_buildMetaItem(
icon: Icons.schedule,
text: article.readingTimeText,
),
// // Views
// _buildMetaItem(
// icon: Icons.visibility,
// text: '${article.formattedViewCount} lượt xem',
// ),
//
// // Reading time
// _buildMetaItem(
// icon: Icons.schedule,
// text: article.readingTimeText,
// ),
],
),
),

View File

@@ -133,19 +133,19 @@ class NewsCard extends StatelessWidget {
const SizedBox(width: 16),
// Views
Icon(
Icons.visibility,
size: 12,
color: const Color(0xFF64748B),
),
const SizedBox(width: 4),
Text(
'${article.formattedViewCount} lượt xem',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF64748B),
),
),
// Icon(
// Icons.visibility,
// size: 12,
// color: const Color(0xFF64748B),
// ),
// const SizedBox(width: 4),
// Text(
// '${article.formattedViewCount} lượt xem',
// style: const TextStyle(
// fontSize: 12,
// color: Color(0xFF64748B),
// ),
// ),
],
),
],

View File

@@ -353,6 +353,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.2"
dio_intercept_to_curl:
dependency: "direct main"
description:
name: dio_intercept_to_curl
sha256: aaf22b05858385ee00dfabd8a1c74265f2e9057a266047a75d81c468f1fcf854
url: "https://pub.dev"
source: hosted
version: "0.2.0"
dio_web_adapter:
dependency: transitive
description:

View File

@@ -52,6 +52,7 @@ dependencies:
connectivity_plus: ^6.0.3
pretty_dio_logger: ^1.3.1
curl_logger_dio_interceptor: ^1.0.0
dio_intercept_to_curl: ^0.2.0
dio_cache_interceptor: ^3.5.0
dio_cache_interceptor_hive_store: ^3.2.2