add auth
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'3f0562ffb573be47d8aae8beebccb1946240cbb6';
|
||||
|
||||
/// Authentication Provider
|
||||
///
|
||||
|
||||
@@ -103,24 +103,24 @@ class HomePage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// Promotions Section
|
||||
SliverToBoxAdapter(
|
||||
child: promotionsAsync.when(
|
||||
data: (promotions) => promotions.isNotEmpty
|
||||
? PromotionSlider(
|
||||
promotions: promotions,
|
||||
onPromotionTap: (promotion) {
|
||||
// Navigate to promotion details
|
||||
context.push('/promotions/${promotion.id}');
|
||||
},
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
// SliverToBoxAdapter(
|
||||
// child: promotionsAsync.when(
|
||||
// data: (promotions) => promotions.isNotEmpty
|
||||
// ? PromotionSlider(
|
||||
// promotions: promotions,
|
||||
// onPromotionTap: (promotion) {
|
||||
// // Navigate to promotion details
|
||||
// context.push('/promotions/${promotion.id}');
|
||||
// },
|
||||
// )
|
||||
// : const SizedBox.shrink(),
|
||||
// loading: () => const Padding(
|
||||
// padding: EdgeInsets.all(16),
|
||||
// child: Center(child: CircularProgressIndicator()),
|
||||
// ),
|
||||
// error: (error, stack) => const SizedBox.shrink(),
|
||||
// ),
|
||||
// ),
|
||||
|
||||
// Quick Action Sections
|
||||
SliverToBoxAdapter(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/// News Remote DataSource
|
||||
///
|
||||
/// Handles fetching news/blog data from the Frappe API.
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:worker/core/constants/api_constants.dart';
|
||||
import 'package:worker/core/network/dio_client.dart';
|
||||
import 'package:worker/core/services/frappe_auth_service.dart';
|
||||
import 'package:worker/features/news/data/models/blog_category_model.dart';
|
||||
|
||||
/// News Remote Data Source
|
||||
///
|
||||
/// Provides methods to fetch news and blog content from the Frappe API.
|
||||
/// Uses FrappeAuthService for session management.
|
||||
class NewsRemoteDataSource {
|
||||
NewsRemoteDataSource(this._dioClient, this._frappeAuthService);
|
||||
|
||||
final DioClient _dioClient;
|
||||
final FrappeAuthService _frappeAuthService;
|
||||
|
||||
/// Get blog categories
|
||||
///
|
||||
/// Fetches all published blog categories from Frappe.
|
||||
/// Returns a list of [BlogCategoryModel].
|
||||
///
|
||||
/// API endpoint: POST https://land.dbiz.com/api/method/frappe.client.get_list
|
||||
/// Request body:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "doctype": "Blog Category",
|
||||
/// "fields": ["title","name"],
|
||||
/// "filters": {"published":1},
|
||||
/// "order_by": "creation desc",
|
||||
/// "limit_page_length": 0
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Response format:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "message": [
|
||||
/// {"title": "Tin tức", "name": "tin-tức"},
|
||||
/// {"title": "Chuyên môn", "name": "chuyên-môn"},
|
||||
/// ...
|
||||
/// ]
|
||||
/// }
|
||||
/// ```
|
||||
Future<List<BlogCategoryModel>> getBlogCategories() async {
|
||||
try {
|
||||
// Get Frappe session headers
|
||||
final headers = await _frappeAuthService.getHeaders();
|
||||
|
||||
// Build full API URL
|
||||
final url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}';
|
||||
|
||||
final response = await _dioClient.post<Map<String, dynamic>>(
|
||||
url,
|
||||
data: {
|
||||
'doctype': 'Blog Category',
|
||||
'fields': ['title', 'name'],
|
||||
'filters': {'published': 1},
|
||||
'order_by': 'creation desc',
|
||||
'limit_page_length': 0,
|
||||
},
|
||||
options: Options(headers: headers),
|
||||
);
|
||||
|
||||
if (response.data == null) {
|
||||
throw Exception('Empty response from server');
|
||||
}
|
||||
|
||||
// Parse the response using the wrapper model
|
||||
final categoriesResponse = BlogCategoriesResponse.fromJson(response.data!);
|
||||
|
||||
return categoriesResponse.message;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) {
|
||||
throw Exception('Blog categories endpoint not found');
|
||||
} else if (e.response?.statusCode == 500) {
|
||||
throw Exception('Server error while fetching blog categories');
|
||||
} else if (e.type == DioExceptionType.connectionTimeout) {
|
||||
throw Exception('Connection timeout while fetching blog categories');
|
||||
} else if (e.type == DioExceptionType.receiveTimeout) {
|
||||
throw Exception('Response timeout while fetching blog categories');
|
||||
} else {
|
||||
throw Exception('Failed to fetch blog categories: ${e.message}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Unexpected error fetching blog categories: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
128
lib/features/news/data/models/blog_category_model.dart
Normal file
128
lib/features/news/data/models/blog_category_model.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
/// Data Model: Blog Category
|
||||
///
|
||||
/// Data Transfer Object for blog/news category information from Frappe API.
|
||||
/// This model handles JSON serialization/deserialization for API responses.
|
||||
library;
|
||||
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:worker/features/news/domain/entities/blog_category.dart';
|
||||
|
||||
part 'blog_category_model.g.dart';
|
||||
|
||||
/// Blog Category Model
|
||||
///
|
||||
/// Used for:
|
||||
/// - API JSON serialization/deserialization
|
||||
/// - Converting to/from domain entity
|
||||
///
|
||||
/// Example API response:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "title": "Tin tức",
|
||||
/// "name": "tin-tức"
|
||||
/// }
|
||||
/// ```
|
||||
@JsonSerializable()
|
||||
class BlogCategoryModel {
|
||||
/// Display title of the category (e.g., "Tin tức", "Chuyên môn")
|
||||
final String title;
|
||||
|
||||
/// URL-safe name/slug of the category (e.g., "tin-tức", "chuyên-môn")
|
||||
final String name;
|
||||
|
||||
const BlogCategoryModel({
|
||||
required this.title,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
/// From JSON constructor
|
||||
factory BlogCategoryModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$BlogCategoryModelFromJson(json);
|
||||
|
||||
/// To JSON method
|
||||
Map<String, dynamic> toJson() => _$BlogCategoryModelToJson(this);
|
||||
|
||||
/// Convert to domain entity
|
||||
BlogCategory toEntity() {
|
||||
return BlogCategory(
|
||||
title: title,
|
||||
name: name,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create from domain entity
|
||||
factory BlogCategoryModel.fromEntity(BlogCategory entity) {
|
||||
return BlogCategoryModel(
|
||||
title: entity.title,
|
||||
name: entity.name,
|
||||
);
|
||||
}
|
||||
|
||||
/// Copy with method for creating modified copies
|
||||
BlogCategoryModel copyWith({
|
||||
String? title,
|
||||
String? name,
|
||||
}) {
|
||||
return BlogCategoryModel(
|
||||
title: title ?? this.title,
|
||||
name: name ?? this.name,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BlogCategoryModel(title: $title, name: $name)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is BlogCategoryModel &&
|
||||
other.title == title &&
|
||||
other.name == name;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(title, name);
|
||||
}
|
||||
}
|
||||
|
||||
/// API Response wrapper for blog categories list
|
||||
///
|
||||
/// Frappe API wraps the response in a "message" field.
|
||||
/// Example:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "message": [
|
||||
/// {"title": "Tin tức", "name": "tin-tức"},
|
||||
/// {"title": "Chuyên môn", "name": "chuyên-môn"}
|
||||
/// ]
|
||||
/// }
|
||||
/// ```
|
||||
class BlogCategoriesResponse {
|
||||
/// List of blog categories
|
||||
final List<BlogCategoryModel> message;
|
||||
|
||||
BlogCategoriesResponse({
|
||||
required this.message,
|
||||
});
|
||||
|
||||
/// From JSON constructor
|
||||
factory BlogCategoriesResponse.fromJson(Map<String, dynamic> json) {
|
||||
final messageList = json['message'] as List<dynamic>;
|
||||
final categories = messageList
|
||||
.map((item) => BlogCategoryModel.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
return BlogCategoriesResponse(message: categories);
|
||||
}
|
||||
|
||||
/// To JSON method
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'message': message.map((category) => category.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
19
lib/features/news/data/models/blog_category_model.g.dart
Normal file
19
lib/features/news/data/models/blog_category_model.g.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'blog_category_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
BlogCategoryModel _$BlogCategoryModelFromJson(Map<String, dynamic> json) =>
|
||||
$checkedCreate('BlogCategoryModel', json, ($checkedConvert) {
|
||||
final val = BlogCategoryModel(
|
||||
title: $checkedConvert('title', (v) => v as String),
|
||||
name: $checkedConvert('name', (v) => v as String),
|
||||
);
|
||||
return val;
|
||||
});
|
||||
|
||||
Map<String, dynamic> _$BlogCategoryModelToJson(BlogCategoryModel instance) =>
|
||||
<String, dynamic>{'title': instance.title, 'name': instance.name};
|
||||
@@ -5,6 +5,8 @@
|
||||
library;
|
||||
|
||||
import 'package:worker/features/news/data/datasources/news_local_datasource.dart';
|
||||
import 'package:worker/features/news/data/datasources/news_remote_datasource.dart';
|
||||
import 'package:worker/features/news/domain/entities/blog_category.dart';
|
||||
import 'package:worker/features/news/domain/entities/news_article.dart';
|
||||
import 'package:worker/features/news/domain/repositories/news_repository.dart';
|
||||
|
||||
@@ -13,8 +15,30 @@ class NewsRepositoryImpl implements NewsRepository {
|
||||
/// Local data source
|
||||
final NewsLocalDataSource localDataSource;
|
||||
|
||||
/// Remote data source
|
||||
final NewsRemoteDataSource remoteDataSource;
|
||||
|
||||
/// Constructor
|
||||
NewsRepositoryImpl({required this.localDataSource});
|
||||
NewsRepositoryImpl({
|
||||
required this.localDataSource,
|
||||
required this.remoteDataSource,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<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 {
|
||||
|
||||
98
lib/features/news/domain/entities/blog_category.dart
Normal file
98
lib/features/news/domain/entities/blog_category.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
/// Domain Entity: Blog Category
|
||||
///
|
||||
/// Represents a blog/news category from the Frappe CMS.
|
||||
/// This entity contains category information for filtering news articles.
|
||||
///
|
||||
/// This is a pure domain entity with no external dependencies.
|
||||
library;
|
||||
|
||||
/// Blog Category Entity
|
||||
///
|
||||
/// Contains information needed to display and filter blog categories:
|
||||
/// - Display title (Vietnamese)
|
||||
/// - URL-safe name/slug
|
||||
///
|
||||
/// Categories from the API:
|
||||
/// - Tin tức (News)
|
||||
/// - Chuyên môn (Professional/Technical)
|
||||
/// - Dự án (Projects)
|
||||
/// - Khuyến mãi (Promotions)
|
||||
class BlogCategory {
|
||||
/// Display title of the category (e.g., "Tin tức", "Chuyên môn")
|
||||
final String title;
|
||||
|
||||
/// URL-safe name/slug of the category (e.g., "tin-tức", "chuyên-môn")
|
||||
/// Used for API filtering and routing
|
||||
final String name;
|
||||
|
||||
/// Constructor
|
||||
const BlogCategory({
|
||||
required this.title,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
/// Get category icon name based on the category
|
||||
String get iconName {
|
||||
switch (name) {
|
||||
case 'tin-tức':
|
||||
return 'newspaper';
|
||||
case 'chuyên-môn':
|
||||
return 'school';
|
||||
case 'dự-án':
|
||||
return 'construction';
|
||||
case 'khuyến-mãi':
|
||||
return 'local_offer';
|
||||
default:
|
||||
return 'category';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get category color based on the category
|
||||
String get colorHex {
|
||||
switch (name) {
|
||||
case 'tin-tức':
|
||||
return '#005B9A'; // Primary blue
|
||||
case 'chuyên-môn':
|
||||
return '#2E7D32'; // Green
|
||||
case 'dự-án':
|
||||
return '#F57C00'; // Orange
|
||||
case 'khuyến-mãi':
|
||||
return '#C62828'; // Red
|
||||
default:
|
||||
return '#757575'; // Grey
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy with method for immutability
|
||||
BlogCategory copyWith({
|
||||
String? title,
|
||||
String? name,
|
||||
}) {
|
||||
return BlogCategory(
|
||||
title: title ?? this.title,
|
||||
name: name ?? this.name,
|
||||
);
|
||||
}
|
||||
|
||||
/// Equality operator
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is BlogCategory &&
|
||||
other.title == title &&
|
||||
other.name == name;
|
||||
}
|
||||
|
||||
/// Hash code
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(title, name);
|
||||
}
|
||||
|
||||
/// String representation
|
||||
@override
|
||||
String toString() {
|
||||
return 'BlogCategory(title: $title, name: $name)';
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,16 @@
|
||||
/// This is an abstract interface following the Repository Pattern.
|
||||
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,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;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -148,7 +165,7 @@ class NewsListPage extends ConsumerWidget {
|
||||
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
|
||||
|
||||
// News List
|
||||
filteredArticlesAsync.when(
|
||||
filteredArticles.when(
|
||||
data: (articles) {
|
||||
if (articles.isEmpty) {
|
||||
return SliverFillRemaining(child: _buildEmptyState());
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/core/network/dio_client.dart';
|
||||
import 'package:worker/core/services/frappe_auth_provider.dart';
|
||||
import 'package:worker/features/news/data/datasources/news_local_datasource.dart';
|
||||
import 'package:worker/features/news/data/repositories/news_repository_impl.dart';
|
||||
import 'package:worker/features/news/data/datasources/news_remote_datasource.dart';
|
||||
import 'package:worker/features/news/domain/entities/blog_category.dart';
|
||||
import 'package:worker/features/news/domain/entities/news_article.dart';
|
||||
import 'package:worker/features/news/domain/repositories/news_repository.dart';
|
||||
import 'package:worker/features/news/data/repositories/news_repository_impl.dart';
|
||||
|
||||
part 'news_provider.g.dart';
|
||||
|
||||
@@ -20,13 +24,27 @@ NewsLocalDataSource newsLocalDataSource(Ref ref) {
|
||||
return NewsLocalDataSource();
|
||||
}
|
||||
|
||||
/// News Remote DataSource Provider
|
||||
///
|
||||
/// Provides instance of NewsRemoteDataSource with Frappe auth service.
|
||||
@riverpod
|
||||
Future<NewsRemoteDataSource> 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
|
||||
@@ -35,7 +53,7 @@ NewsRepository newsRepository(Ref ref) {
|
||||
/// Returns AsyncValue<List<NewsArticle>> for proper loading/error handling.
|
||||
@riverpod
|
||||
Future<List<NewsArticle>> newsArticles(Ref ref) async {
|
||||
final repository = ref.watch(newsRepositoryProvider);
|
||||
final repository = await ref.watch(newsRepositoryProvider.future);
|
||||
return repository.getAllArticles();
|
||||
}
|
||||
|
||||
@@ -45,7 +63,7 @@ Future<List<NewsArticle>> newsArticles(Ref ref) async {
|
||||
/// Returns AsyncValue<NewsArticle?> (null if no featured article).
|
||||
@riverpod
|
||||
Future<NewsArticle?> featuredArticle(Ref ref) async {
|
||||
final repository = ref.watch(newsRepositoryProvider);
|
||||
final repository = await ref.watch(newsRepositoryProvider.future);
|
||||
return repository.getFeaturedArticle();
|
||||
}
|
||||
|
||||
@@ -79,7 +97,7 @@ class SelectedNewsCategory extends _$SelectedNewsCategory {
|
||||
@riverpod
|
||||
Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
|
||||
final selectedCategory = ref.watch(selectedNewsCategoryProvider);
|
||||
final repository = ref.watch(newsRepositoryProvider);
|
||||
final repository = await ref.watch(newsRepositoryProvider.future);
|
||||
|
||||
// If no category selected, return all articles
|
||||
if (selectedCategory == null) {
|
||||
@@ -96,6 +114,22 @@ Future<List<NewsArticle>> filteredNewsArticles(Ref ref) async {
|
||||
/// Used for article detail page.
|
||||
@riverpod
|
||||
Future<NewsArticle?> newsArticleById(Ref ref, String articleId) async {
|
||||
final repository = ref.watch(newsRepositoryProvider);
|
||||
final repository = await ref.watch(newsRepositoryProvider.future);
|
||||
return repository.getArticleById(articleId);
|
||||
}
|
||||
|
||||
/// Blog Categories Provider
|
||||
///
|
||||
/// Fetches all published blog categories from Frappe API.
|
||||
/// Returns AsyncValue<List<BlogCategory>> (domain entities) for proper loading/error handling.
|
||||
///
|
||||
/// Example categories:
|
||||
/// - Tin tức (News)
|
||||
/// - Chuyên môn (Professional)
|
||||
/// - Dự án (Projects)
|
||||
/// - Khuyến mãi (Promotions)
|
||||
@riverpod
|
||||
Future<List<BlogCategory>> blogCategories(Ref ref) async {
|
||||
final repository = await ref.watch(newsRepositoryProvider.future);
|
||||
return repository.getBlogCategories();
|
||||
}
|
||||
|
||||
@@ -67,6 +67,59 @@ final class NewsLocalDataSourceProvider
|
||||
String _$newsLocalDataSourceHash() =>
|
||||
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,24 +158,17 @@ 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';
|
||||
|
||||
/// News Articles Provider
|
||||
///
|
||||
@@ -172,7 +223,7 @@ final class NewsArticlesProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$newsArticlesHash() => r'24d70e49f7137c614c024dc93c97451c6e161ce6';
|
||||
String _$newsArticlesHash() => r'789d916f1ce7d76f26429cfce97c65a71915edf3';
|
||||
|
||||
/// Featured Article Provider
|
||||
///
|
||||
@@ -225,7 +276,7 @@ final class FeaturedArticleProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$featuredArticleHash() => r'f7146600bc3bbaf5987ab6b09262135b1558f1c0';
|
||||
String _$featuredArticleHash() => r'5fd7057d3f828d6f717b08d59561aa9637eb0097';
|
||||
|
||||
/// Selected News Category Provider
|
||||
///
|
||||
@@ -353,7 +404,7 @@ final class FilteredNewsArticlesProvider
|
||||
}
|
||||
|
||||
String _$filteredNewsArticlesHash() =>
|
||||
r'f40a737b74b44f2d4fa86977175314ed0da471fa';
|
||||
r'f5d6faa2d510eae188f12fa41d052eeb43e08cc9';
|
||||
|
||||
/// News Article by ID Provider
|
||||
///
|
||||
@@ -424,7 +475,7 @@ final class NewsArticleByIdProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$newsArticleByIdHash() => r'4d28caa81d486fcd6cfefd16477355927bbcadc8';
|
||||
String _$newsArticleByIdHash() => r'f2b5ee4a3f7b67d0ee9e9c91169d740a9f250b50';
|
||||
|
||||
/// News Article by ID Provider
|
||||
///
|
||||
@@ -453,3 +504,76 @@ final class NewsArticleByIdFamily extends $Family
|
||||
@override
|
||||
String toString() => r'newsArticleByIdProvider';
|
||||
}
|
||||
|
||||
/// Blog Categories Provider
|
||||
///
|
||||
/// Fetches all published blog categories from Frappe API.
|
||||
/// Returns AsyncValue<List<BlogCategory>> (domain entities) for proper loading/error handling.
|
||||
///
|
||||
/// Example categories:
|
||||
/// - Tin tức (News)
|
||||
/// - Chuyên môn (Professional)
|
||||
/// - Dự án (Projects)
|
||||
/// - Khuyến mãi (Promotions)
|
||||
|
||||
@ProviderFor(blogCategories)
|
||||
const blogCategoriesProvider = BlogCategoriesProvider._();
|
||||
|
||||
/// Blog Categories Provider
|
||||
///
|
||||
/// Fetches all published blog categories from Frappe API.
|
||||
/// Returns AsyncValue<List<BlogCategory>> (domain entities) for proper loading/error handling.
|
||||
///
|
||||
/// Example categories:
|
||||
/// - Tin tức (News)
|
||||
/// - Chuyên môn (Professional)
|
||||
/// - Dự án (Projects)
|
||||
/// - Khuyến mãi (Promotions)
|
||||
|
||||
final class BlogCategoriesProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<BlogCategory>>,
|
||||
List<BlogCategory>,
|
||||
FutureOr<List<BlogCategory>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<BlogCategory>>,
|
||||
$FutureProvider<List<BlogCategory>> {
|
||||
/// Blog Categories Provider
|
||||
///
|
||||
/// Fetches all published blog categories from Frappe API.
|
||||
/// Returns AsyncValue<List<BlogCategory>> (domain entities) for proper loading/error handling.
|
||||
///
|
||||
/// Example categories:
|
||||
/// - Tin tức (News)
|
||||
/// - Chuyên môn (Professional)
|
||||
/// - Dự án (Projects)
|
||||
/// - Khuyến mãi (Promotions)
|
||||
const BlogCategoriesProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'blogCategoriesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$blogCategoriesHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<BlogCategory>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<BlogCategory>> create(Ref ref) {
|
||||
return blogCategories(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$blogCategoriesHash() => r'd87493142946be20ab309ea94d6173a8005b516e';
|
||||
|
||||
@@ -2,37 +2,53 @@
|
||||
///
|
||||
/// Horizontal scrollable list of category filter chips.
|
||||
/// 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,
|
||||
|
||||
Reference in New Issue
Block a user