add auth, format
This commit is contained in:
16
lib/app.dart
16
lib/app.dart
@@ -33,7 +33,6 @@ class WorkerApp extends ConsumerWidget {
|
||||
theme: AppTheme.lightTheme(),
|
||||
darkTheme: AppTheme.darkTheme(),
|
||||
themeMode: ThemeMode.light, // TODO: Make this configurable from settings
|
||||
|
||||
// ==================== Localization Configuration ====================
|
||||
// Support for Vietnamese (primary) and English (secondary)
|
||||
localizationsDelegates: const [
|
||||
@@ -53,8 +52,10 @@ class WorkerApp extends ConsumerWidget {
|
||||
],
|
||||
|
||||
// Default locale (Vietnamese)
|
||||
locale: const Locale('vi', 'VN'), // TODO: Make this configurable from settings
|
||||
|
||||
locale: const Locale(
|
||||
'vi',
|
||||
'VN',
|
||||
), // TODO: Make this configurable from settings
|
||||
// Locale resolution strategy
|
||||
localeResolutionCallback: (locale, supportedLocales) {
|
||||
// Check if the device locale is supported
|
||||
@@ -71,9 +72,7 @@ class WorkerApp extends ConsumerWidget {
|
||||
// ==================== Material App Configuration ====================
|
||||
// Builder for additional context-dependent widgets
|
||||
builder: (context, child) {
|
||||
return _AppBuilder(
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
);
|
||||
return _AppBuilder(child: child ?? const SizedBox.shrink());
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -86,9 +85,7 @@ class WorkerApp extends ConsumerWidget {
|
||||
/// - Connectivity listener
|
||||
/// - Global overlays (loading, snackbars)
|
||||
class _AppBuilder extends ConsumerWidget {
|
||||
const _AppBuilder({
|
||||
required this.child,
|
||||
});
|
||||
const _AppBuilder({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@@ -116,4 +113,3 @@ class _AppBuilder extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -398,7 +398,10 @@ class ApiConstants {
|
||||
/// final url = ApiConstants.buildUrlWithParams('/products/{id}', {'id': '123'});
|
||||
/// // Returns: https://api.worker.example.com/v1/products/123
|
||||
/// ```
|
||||
static String buildUrlWithParams(String endpoint, Map<String, String> params) {
|
||||
static String buildUrlWithParams(
|
||||
String endpoint,
|
||||
Map<String, String> params,
|
||||
) {
|
||||
String url = endpoint;
|
||||
params.forEach((key, value) {
|
||||
url = url.replaceAll('{$key}', value);
|
||||
|
||||
@@ -440,7 +440,12 @@ class AppConstants {
|
||||
static const int maxProductImageSize = 3;
|
||||
|
||||
/// Supported image formats
|
||||
static const List<String> supportedImageFormats = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
static const List<String> supportedImageFormats = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'webp',
|
||||
];
|
||||
|
||||
/// Image quality for compression (0-100)
|
||||
static const int imageQuality = 85;
|
||||
|
||||
@@ -59,22 +59,22 @@ class HiveBoxNames {
|
||||
|
||||
/// Get all box names for initialization
|
||||
static List<String> get allBoxes => [
|
||||
userBox,
|
||||
productBox,
|
||||
cartBox,
|
||||
orderBox,
|
||||
projectBox,
|
||||
quotes,
|
||||
loyaltyBox,
|
||||
rewardsBox,
|
||||
settingsBox,
|
||||
cacheBox,
|
||||
syncStateBox,
|
||||
notificationBox,
|
||||
addressBox,
|
||||
favoriteBox,
|
||||
offlineQueueBox,
|
||||
];
|
||||
userBox,
|
||||
productBox,
|
||||
cartBox,
|
||||
orderBox,
|
||||
projectBox,
|
||||
quotes,
|
||||
loyaltyBox,
|
||||
rewardsBox,
|
||||
settingsBox,
|
||||
cacheBox,
|
||||
syncStateBox,
|
||||
notificationBox,
|
||||
addressBox,
|
||||
favoriteBox,
|
||||
offlineQueueBox,
|
||||
];
|
||||
}
|
||||
|
||||
/// Hive Type Adapter IDs
|
||||
@@ -152,7 +152,8 @@ class HiveTypeIds {
|
||||
// Aliases for backward compatibility and clarity
|
||||
static const int memberTier = loyaltyTier; // Alias for loyaltyTier
|
||||
static const int userType = userRole; // Alias for userRole
|
||||
static const int projectStatus = submissionStatus; // Alias for submissionStatus
|
||||
static const int projectStatus =
|
||||
submissionStatus; // Alias for submissionStatus
|
||||
static const int transactionType = entryType; // Alias for entryType
|
||||
|
||||
// Cache & Sync Models (60-69)
|
||||
|
||||
@@ -17,14 +17,16 @@ import 'package:worker/core/database/hive_service.dart';
|
||||
/// - Sync state tracking
|
||||
class DatabaseManager {
|
||||
DatabaseManager({HiveService? hiveService})
|
||||
: _hiveService = hiveService ?? HiveService();
|
||||
: _hiveService = hiveService ?? HiveService();
|
||||
|
||||
final HiveService _hiveService;
|
||||
|
||||
/// Get a box safely
|
||||
Box<T> _getBox<T>(String boxName) {
|
||||
if (!_hiveService.isBoxOpen(boxName)) {
|
||||
throw HiveError('Box $boxName is not open. Initialize HiveService first.');
|
||||
throw HiveError(
|
||||
'Box $boxName is not open. Initialize HiveService first.',
|
||||
);
|
||||
}
|
||||
return _hiveService.getBox<T>(boxName);
|
||||
}
|
||||
@@ -49,11 +51,7 @@ class DatabaseManager {
|
||||
}
|
||||
|
||||
/// Get a value from a box
|
||||
T? get<T>({
|
||||
required String boxName,
|
||||
required String key,
|
||||
T? defaultValue,
|
||||
}) {
|
||||
T? get<T>({required String boxName, required String key, T? defaultValue}) {
|
||||
try {
|
||||
final box = _getBox<T>(boxName);
|
||||
return box.get(key, defaultValue: defaultValue);
|
||||
@@ -65,10 +63,7 @@ class DatabaseManager {
|
||||
}
|
||||
|
||||
/// Delete a value from a box
|
||||
Future<void> delete({
|
||||
required String boxName,
|
||||
required String key,
|
||||
}) async {
|
||||
Future<void> delete({required String boxName, required String key}) async {
|
||||
try {
|
||||
final box = _getBox<dynamic>(boxName);
|
||||
await box.delete(key);
|
||||
@@ -81,10 +76,7 @@ class DatabaseManager {
|
||||
}
|
||||
|
||||
/// Check if a key exists in a box
|
||||
bool exists({
|
||||
required String boxName,
|
||||
required String key,
|
||||
}) {
|
||||
bool exists({required String boxName, required String key}) {
|
||||
try {
|
||||
final box = _getBox<dynamic>(boxName);
|
||||
return box.containsKey(key);
|
||||
@@ -138,10 +130,7 @@ class DatabaseManager {
|
||||
// ==================== Cache Operations ====================
|
||||
|
||||
/// Save data to cache with timestamp
|
||||
Future<void> saveToCache<T>({
|
||||
required String key,
|
||||
required T data,
|
||||
}) async {
|
||||
Future<void> saveToCache<T>({required String key, required T data}) async {
|
||||
try {
|
||||
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
|
||||
await cacheBox.put(key, {
|
||||
@@ -159,10 +148,7 @@ class DatabaseManager {
|
||||
/// Get data from cache
|
||||
///
|
||||
/// Returns null if cache is expired or doesn't exist
|
||||
T? getFromCache<T>({
|
||||
required String key,
|
||||
Duration? maxAge,
|
||||
}) {
|
||||
T? getFromCache<T>({required String key, Duration? maxAge}) {
|
||||
try {
|
||||
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
|
||||
final cachedData = cacheBox.get(key) as Map<dynamic, dynamic>?;
|
||||
@@ -193,10 +179,7 @@ class DatabaseManager {
|
||||
}
|
||||
|
||||
/// Check if cache is valid (exists and not expired)
|
||||
bool isCacheValid({
|
||||
required String key,
|
||||
Duration? maxAge,
|
||||
}) {
|
||||
bool isCacheValid({required String key, Duration? maxAge}) {
|
||||
try {
|
||||
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
|
||||
final cachedData = cacheBox.get(key) as Map<dynamic, dynamic>?;
|
||||
@@ -244,7 +227,9 @@ class DatabaseManager {
|
||||
await cacheBox.delete(key);
|
||||
}
|
||||
|
||||
debugPrint('DatabaseManager: Cleared ${keysToDelete.length} expired cache entries');
|
||||
debugPrint(
|
||||
'DatabaseManager: Cleared ${keysToDelete.length} expired cache entries',
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('DatabaseManager: Error clearing expired cache: $e');
|
||||
debugPrint('StackTrace: $stackTrace');
|
||||
@@ -281,10 +266,7 @@ class DatabaseManager {
|
||||
}
|
||||
|
||||
/// Check if data needs sync
|
||||
bool needsSync({
|
||||
required String dataType,
|
||||
required Duration syncInterval,
|
||||
}) {
|
||||
bool needsSync({required String dataType, required Duration syncInterval}) {
|
||||
final lastSync = getLastSyncTime(dataType);
|
||||
|
||||
if (lastSync == null) return true;
|
||||
@@ -296,22 +278,12 @@ class DatabaseManager {
|
||||
// ==================== Settings Operations ====================
|
||||
|
||||
/// Save a setting
|
||||
Future<void> saveSetting<T>({
|
||||
required String key,
|
||||
required T value,
|
||||
}) async {
|
||||
await save(
|
||||
boxName: HiveBoxNames.settingsBox,
|
||||
key: key,
|
||||
value: value,
|
||||
);
|
||||
Future<void> saveSetting<T>({required String key, required T value}) async {
|
||||
await save(boxName: HiveBoxNames.settingsBox, key: key, value: value);
|
||||
}
|
||||
|
||||
/// Get a setting
|
||||
T? getSetting<T>({
|
||||
required String key,
|
||||
T? defaultValue,
|
||||
}) {
|
||||
T? getSetting<T>({required String key, T? defaultValue}) {
|
||||
return get(
|
||||
boxName: HiveBoxNames.settingsBox,
|
||||
key: key,
|
||||
@@ -328,7 +300,9 @@ class DatabaseManager {
|
||||
|
||||
// Check queue size limit
|
||||
if (queueBox.length >= HiveDatabaseConfig.maxOfflineQueueSize) {
|
||||
debugPrint('DatabaseManager: Offline queue is full, removing oldest item');
|
||||
debugPrint(
|
||||
'DatabaseManager: Offline queue is full, removing oldest item',
|
||||
);
|
||||
await queueBox.deleteAt(0);
|
||||
}
|
||||
|
||||
@@ -386,10 +360,7 @@ class DatabaseManager {
|
||||
try {
|
||||
if (_hiveService.isBoxOpen(boxName)) {
|
||||
final box = _getBox<dynamic>(boxName);
|
||||
stats[boxName] = {
|
||||
'count': box.length,
|
||||
'keys': box.keys.length,
|
||||
};
|
||||
stats[boxName] = {'count': box.length, 'keys': box.keys.length};
|
||||
}
|
||||
} catch (e) {
|
||||
stats[boxName] = {'error': e.toString()};
|
||||
|
||||
@@ -5,9 +5,7 @@ import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'package:worker/core/constants/storage_constants.dart';
|
||||
import 'package:worker/features/favorites/data/models/favorite_model.dart';
|
||||
// TODO: Re-enable when build_runner generates this file successfully
|
||||
// import 'package:worker/hive_registrar.g.dart';
|
||||
import 'package:worker/hive_registrar.g.dart';
|
||||
|
||||
/// Hive CE (Community Edition) Database Service
|
||||
///
|
||||
@@ -92,40 +90,45 @@ class HiveService {
|
||||
debugPrint('HiveService: Registering type adapters...');
|
||||
|
||||
// Register all adapters using the auto-generated extension
|
||||
// This automatically registers:
|
||||
// - CachedDataAdapter (typeId: 30)
|
||||
// - All enum adapters (typeIds: 20-29)
|
||||
// TODO: Re-enable when build_runner generates hive_registrar.g.dart successfully
|
||||
// Hive.registerAdapters();
|
||||
// This automatically registers all model and enum adapters
|
||||
Hive.registerAdapters();
|
||||
|
||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.memberTier) ? "✓" : "✗"} MemberTier adapter');
|
||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.userType) ? "✓" : "✗"} UserType adapter');
|
||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.orderStatus) ? "✓" : "✗"} OrderStatus adapter');
|
||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectStatus) ? "✓" : "✗"} ProjectStatus adapter');
|
||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectType) ? "✓" : "✗"} ProjectType adapter');
|
||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.transactionType) ? "✓" : "✗"} TransactionType adapter');
|
||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.giftStatus) ? "✓" : "✗"} GiftStatus adapter');
|
||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentStatus) ? "✓" : "✗"} PaymentStatus adapter');
|
||||
// NotificationType adapter not needed - notification model uses String type
|
||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentMethod) ? "✓" : "✗"} PaymentMethod adapter');
|
||||
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.cachedData) ? "✓" : "✗"} CachedData adapter');
|
||||
|
||||
// Register model type adapters manually
|
||||
// FavoriteModel adapter (typeId: 28)
|
||||
if (!Hive.isAdapterRegistered(HiveTypeIds.favoriteModel)) {
|
||||
Hive.registerAdapter(FavoriteModelAdapter());
|
||||
debugPrint('HiveService: ✓ FavoriteModel adapter registered');
|
||||
}
|
||||
|
||||
// TODO: Register other model type adapters when created
|
||||
// Example:
|
||||
// - UserModel (typeId: 0)
|
||||
// - ProductModel (typeId: 1)
|
||||
// - CartItemModel (typeId: 2)
|
||||
// - OrderModel (typeId: 3)
|
||||
// - ProjectModel (typeId: 4)
|
||||
// - LoyaltyTransactionModel (typeId: 5)
|
||||
// etc.
|
||||
debugPrint(
|
||||
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.loyaltyTier) ? "✓" : "✗"} LoyaltyTier adapter',
|
||||
);
|
||||
debugPrint(
|
||||
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.userRole) ? "✓" : "✗"} UserRole adapter',
|
||||
);
|
||||
debugPrint(
|
||||
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.orderStatus) ? "✓" : "✗"} OrderStatus adapter',
|
||||
);
|
||||
debugPrint(
|
||||
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectType) ? "✓" : "✗"} ProjectType adapter',
|
||||
);
|
||||
debugPrint(
|
||||
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.entryType) ? "✓" : "✗"} EntryType adapter',
|
||||
);
|
||||
debugPrint(
|
||||
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.giftStatus) ? "✓" : "✗"} GiftStatus adapter',
|
||||
);
|
||||
debugPrint(
|
||||
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentStatus) ? "✓" : "✗"} PaymentStatus adapter',
|
||||
);
|
||||
debugPrint(
|
||||
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentMethod) ? "✓" : "✗"} PaymentMethod adapter',
|
||||
);
|
||||
debugPrint(
|
||||
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.cachedData) ? "✓" : "✗"} CachedData adapter',
|
||||
);
|
||||
debugPrint(
|
||||
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.favoriteModel) ? "✓" : "✗"} FavoriteModel adapter',
|
||||
);
|
||||
debugPrint(
|
||||
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.productModel) ? "✓" : "✗"} ProductModel adapter',
|
||||
);
|
||||
debugPrint(
|
||||
'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.userModel) ? "✓" : "✗"} UserModel adapter',
|
||||
);
|
||||
|
||||
debugPrint('HiveService: Type adapters registered successfully');
|
||||
}
|
||||
@@ -188,21 +191,23 @@ class HiveService {
|
||||
/// Handles schema version upgrades and data migrations.
|
||||
Future<void> _performMigrations() async {
|
||||
final settingsBox = Hive.box<dynamic>(HiveBoxNames.settingsBox);
|
||||
final currentVersion = settingsBox.get(
|
||||
HiveKeys.schemaVersion,
|
||||
defaultValue: 0,
|
||||
) as int;
|
||||
final currentVersion =
|
||||
settingsBox.get(HiveKeys.schemaVersion, defaultValue: 0) as int;
|
||||
|
||||
debugPrint('HiveService: Current schema version: $currentVersion');
|
||||
debugPrint('HiveService: Target schema version: ${HiveDatabaseConfig.currentSchemaVersion}');
|
||||
debugPrint(
|
||||
'HiveService: Target schema version: ${HiveDatabaseConfig.currentSchemaVersion}',
|
||||
);
|
||||
|
||||
if (currentVersion < HiveDatabaseConfig.currentSchemaVersion) {
|
||||
debugPrint('HiveService: Performing migrations...');
|
||||
|
||||
// Perform migrations sequentially
|
||||
for (int version = currentVersion + 1;
|
||||
version <= HiveDatabaseConfig.currentSchemaVersion;
|
||||
version++) {
|
||||
for (
|
||||
int version = currentVersion + 1;
|
||||
version <= HiveDatabaseConfig.currentSchemaVersion;
|
||||
version++
|
||||
) {
|
||||
await _migrateToVersion(version);
|
||||
}
|
||||
|
||||
@@ -278,10 +283,9 @@ class HiveService {
|
||||
|
||||
/// Clear expired cache entries
|
||||
Future<void> _clearExpiredCache() async {
|
||||
final cacheBox = Hive.box<dynamic>(HiveBoxNames.cacheBox);
|
||||
|
||||
// TODO: Implement cache expiration logic
|
||||
// This will be implemented when cache models are created
|
||||
// final cacheBox = Hive.box<dynamic>(HiveBoxNames.cacheBox);
|
||||
|
||||
debugPrint('HiveService: Cleared expired cache entries');
|
||||
}
|
||||
@@ -291,14 +295,17 @@ class HiveService {
|
||||
final queueBox = Hive.box<dynamic>(HiveBoxNames.offlineQueueBox);
|
||||
|
||||
if (queueBox.length > HiveDatabaseConfig.maxOfflineQueueSize) {
|
||||
final itemsToRemove = queueBox.length - HiveDatabaseConfig.maxOfflineQueueSize;
|
||||
final itemsToRemove =
|
||||
queueBox.length - HiveDatabaseConfig.maxOfflineQueueSize;
|
||||
|
||||
// Remove oldest items
|
||||
for (int i = 0; i < itemsToRemove; i++) {
|
||||
await queueBox.deleteAt(0);
|
||||
}
|
||||
|
||||
debugPrint('HiveService: Removed $itemsToRemove old items from offline queue');
|
||||
debugPrint(
|
||||
'HiveService: Removed $itemsToRemove old items from offline queue',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,35 +10,27 @@ library;
|
||||
|
||||
/// Base exception for all network-related errors
|
||||
class NetworkException implements Exception {
|
||||
const NetworkException(
|
||||
this.message, {
|
||||
this.statusCode,
|
||||
this.data,
|
||||
});
|
||||
const NetworkException(this.message, {this.statusCode, this.data});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final dynamic data;
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}';
|
||||
String toString() =>
|
||||
'NetworkException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception thrown when there's no internet connection
|
||||
class NoInternetException extends NetworkException {
|
||||
const NoInternetException()
|
||||
: super(
|
||||
'Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.',
|
||||
);
|
||||
: super('Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.');
|
||||
}
|
||||
|
||||
/// Exception thrown when connection times out
|
||||
class TimeoutException extends NetworkException {
|
||||
const TimeoutException()
|
||||
: super(
|
||||
'Kết nối quá lâu. Vui lòng thử lại.',
|
||||
statusCode: 408,
|
||||
);
|
||||
: super('Kết nối quá lâu. Vui lòng thử lại.', statusCode: 408);
|
||||
}
|
||||
|
||||
/// Exception thrown when server returns 500+ errors
|
||||
@@ -52,10 +44,7 @@ class ServerException extends NetworkException {
|
||||
/// Exception thrown when server is unreachable
|
||||
class ServiceUnavailableException extends ServerException {
|
||||
const ServiceUnavailableException()
|
||||
: super(
|
||||
'Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.',
|
||||
503,
|
||||
);
|
||||
: super('Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.', 503);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -64,10 +53,7 @@ class ServiceUnavailableException extends ServerException {
|
||||
|
||||
/// Base exception for authentication-related errors
|
||||
class AuthException implements Exception {
|
||||
const AuthException(
|
||||
this.message, {
|
||||
this.statusCode,
|
||||
});
|
||||
const AuthException(this.message, {this.statusCode});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
@@ -79,10 +65,7 @@ class AuthException implements Exception {
|
||||
/// Exception thrown when authentication credentials are invalid
|
||||
class InvalidCredentialsException extends AuthException {
|
||||
const InvalidCredentialsException()
|
||||
: super(
|
||||
'Thông tin đăng nhập không hợp lệ.',
|
||||
statusCode: 401,
|
||||
);
|
||||
: super('Thông tin đăng nhập không hợp lệ.', statusCode: 401);
|
||||
}
|
||||
|
||||
/// Exception thrown when user is not authenticated
|
||||
@@ -95,46 +78,37 @@ class UnauthorizedException extends AuthException {
|
||||
/// Exception thrown when user doesn't have permission
|
||||
class ForbiddenException extends AuthException {
|
||||
const ForbiddenException()
|
||||
: super(
|
||||
'Bạn không có quyền truy cập tài nguyên này.',
|
||||
statusCode: 403,
|
||||
);
|
||||
: super('Bạn không có quyền truy cập tài nguyên này.', statusCode: 403);
|
||||
}
|
||||
|
||||
/// Exception thrown when auth token is expired
|
||||
class TokenExpiredException extends AuthException {
|
||||
const TokenExpiredException()
|
||||
: super(
|
||||
'Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.',
|
||||
statusCode: 401,
|
||||
);
|
||||
: super(
|
||||
'Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.',
|
||||
statusCode: 401,
|
||||
);
|
||||
}
|
||||
|
||||
/// Exception thrown when refresh token is invalid
|
||||
class InvalidRefreshTokenException extends AuthException {
|
||||
const InvalidRefreshTokenException()
|
||||
: super(
|
||||
'Không thể làm mới phiên đăng nhập. Vui lòng đăng nhập lại.',
|
||||
statusCode: 401,
|
||||
);
|
||||
: super(
|
||||
'Không thể làm mới phiên đăng nhập. Vui lòng đăng nhập lại.',
|
||||
statusCode: 401,
|
||||
);
|
||||
}
|
||||
|
||||
/// Exception thrown when OTP is invalid
|
||||
class InvalidOTPException extends AuthException {
|
||||
const InvalidOTPException()
|
||||
: super(
|
||||
'Mã OTP không hợp lệ. Vui lòng thử lại.',
|
||||
statusCode: 400,
|
||||
);
|
||||
: super('Mã OTP không hợp lệ. Vui lòng thử lại.', statusCode: 400);
|
||||
}
|
||||
|
||||
/// Exception thrown when OTP is expired
|
||||
class OTPExpiredException extends AuthException {
|
||||
const OTPExpiredException()
|
||||
: super(
|
||||
'Mã OTP đã hết hạn. Vui lòng yêu cầu mã mới.',
|
||||
statusCode: 400,
|
||||
);
|
||||
: super('Mã OTP đã hết hạn. Vui lòng yêu cầu mã mới.', statusCode: 400);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -143,10 +117,7 @@ class OTPExpiredException extends AuthException {
|
||||
|
||||
/// Exception thrown when request data is invalid
|
||||
class ValidationException implements Exception {
|
||||
const ValidationException(
|
||||
this.message, {
|
||||
this.errors,
|
||||
});
|
||||
const ValidationException(this.message, {this.errors});
|
||||
|
||||
final String message;
|
||||
final Map<String, List<String>>? errors;
|
||||
@@ -198,9 +169,7 @@ class NotFoundException implements Exception {
|
||||
|
||||
/// Exception thrown when trying to create a duplicate resource
|
||||
class ConflictException implements Exception {
|
||||
const ConflictException([
|
||||
this.message = 'Tài nguyên đã tồn tại.',
|
||||
]);
|
||||
const ConflictException([this.message = 'Tài nguyên đã tồn tại.']);
|
||||
|
||||
final String message;
|
||||
|
||||
@@ -237,10 +206,7 @@ class RateLimitException implements Exception {
|
||||
|
||||
/// Exception thrown for payment-related errors
|
||||
class PaymentException implements Exception {
|
||||
const PaymentException(
|
||||
this.message, {
|
||||
this.transactionId,
|
||||
});
|
||||
const PaymentException(this.message, {this.transactionId});
|
||||
|
||||
final String message;
|
||||
final String? transactionId;
|
||||
@@ -259,8 +225,7 @@ class PaymentFailedException extends PaymentException {
|
||||
|
||||
/// Exception thrown when payment is cancelled
|
||||
class PaymentCancelledException extends PaymentException {
|
||||
const PaymentCancelledException()
|
||||
: super('Thanh toán đã bị hủy.');
|
||||
const PaymentCancelledException() : super('Thanh toán đã bị hủy.');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -269,9 +234,7 @@ class PaymentCancelledException extends PaymentException {
|
||||
|
||||
/// Exception thrown for cache-related errors
|
||||
class CacheException implements Exception {
|
||||
const CacheException([
|
||||
this.message = 'Lỗi khi truy cập bộ nhớ đệm.',
|
||||
]);
|
||||
const CacheException([this.message = 'Lỗi khi truy cập bộ nhớ đệm.']);
|
||||
|
||||
final String message;
|
||||
|
||||
@@ -281,8 +244,7 @@ class CacheException implements Exception {
|
||||
|
||||
/// Exception thrown when cache data is corrupted
|
||||
class CacheCorruptedException extends CacheException {
|
||||
const CacheCorruptedException()
|
||||
: super('Dữ liệu bộ nhớ đệm bị hỏng.');
|
||||
const CacheCorruptedException() : super('Dữ liệu bộ nhớ đệm bị hỏng.');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -291,9 +253,7 @@ class CacheCorruptedException extends CacheException {
|
||||
|
||||
/// Exception thrown for local storage errors
|
||||
class StorageException implements Exception {
|
||||
const StorageException([
|
||||
this.message = 'Lỗi khi truy cập bộ nhớ cục bộ.',
|
||||
]);
|
||||
const StorageException([this.message = 'Lỗi khi truy cập bộ nhớ cục bộ.']);
|
||||
|
||||
final String message;
|
||||
|
||||
@@ -304,7 +264,7 @@ class StorageException implements Exception {
|
||||
/// Exception thrown when storage is full
|
||||
class StorageFullException extends StorageException {
|
||||
const StorageFullException()
|
||||
: super('Bộ nhớ đã đầy. Vui lòng giải phóng không gian.');
|
||||
: super('Bộ nhớ đã đầy. Vui lòng giải phóng không gian.');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -9,16 +9,12 @@ sealed class Failure {
|
||||
const Failure({required this.message});
|
||||
|
||||
/// Network-related failure
|
||||
const factory Failure.network({
|
||||
required String message,
|
||||
int? statusCode,
|
||||
}) = NetworkFailure;
|
||||
const factory Failure.network({required String message, int? statusCode}) =
|
||||
NetworkFailure;
|
||||
|
||||
/// Server error failure (5xx errors)
|
||||
const factory Failure.server({
|
||||
required String message,
|
||||
int? statusCode,
|
||||
}) = ServerFailure;
|
||||
const factory Failure.server({required String message, int? statusCode}) =
|
||||
ServerFailure;
|
||||
|
||||
/// Authentication failure
|
||||
const factory Failure.authentication({
|
||||
@@ -33,20 +29,14 @@ sealed class Failure {
|
||||
}) = ValidationFailure;
|
||||
|
||||
/// Not found failure (404)
|
||||
const factory Failure.notFound({
|
||||
required String message,
|
||||
}) = NotFoundFailure;
|
||||
const factory Failure.notFound({required String message}) = NotFoundFailure;
|
||||
|
||||
/// Conflict failure (409)
|
||||
const factory Failure.conflict({
|
||||
required String message,
|
||||
}) = ConflictFailure;
|
||||
const factory Failure.conflict({required String message}) = ConflictFailure;
|
||||
|
||||
/// Rate limit exceeded failure (429)
|
||||
const factory Failure.rateLimit({
|
||||
required String message,
|
||||
int? retryAfter,
|
||||
}) = RateLimitFailure;
|
||||
const factory Failure.rateLimit({required String message, int? retryAfter}) =
|
||||
RateLimitFailure;
|
||||
|
||||
/// Payment failure
|
||||
const factory Failure.payment({
|
||||
@@ -55,19 +45,13 @@ sealed class Failure {
|
||||
}) = PaymentFailure;
|
||||
|
||||
/// Cache failure
|
||||
const factory Failure.cache({
|
||||
required String message,
|
||||
}) = CacheFailure;
|
||||
const factory Failure.cache({required String message}) = CacheFailure;
|
||||
|
||||
/// Storage failure
|
||||
const factory Failure.storage({
|
||||
required String message,
|
||||
}) = StorageFailure;
|
||||
const factory Failure.storage({required String message}) = StorageFailure;
|
||||
|
||||
/// Parse failure
|
||||
const factory Failure.parse({
|
||||
required String message,
|
||||
}) = ParseFailure;
|
||||
const factory Failure.parse({required String message}) = ParseFailure;
|
||||
|
||||
/// No internet connection failure
|
||||
const factory Failure.noInternet() = NoInternetFailure;
|
||||
@@ -76,9 +60,7 @@ sealed class Failure {
|
||||
const factory Failure.timeout() = TimeoutFailure;
|
||||
|
||||
/// Unknown failure
|
||||
const factory Failure.unknown({
|
||||
required String message,
|
||||
}) = UnknownFailure;
|
||||
const factory Failure.unknown({required String message}) = UnknownFailure;
|
||||
|
||||
final String message;
|
||||
|
||||
@@ -120,15 +102,21 @@ sealed class Failure {
|
||||
/// Get user-friendly error message
|
||||
String getUserMessage() {
|
||||
return switch (this) {
|
||||
ValidationFailure(:final message, :final errors) => _formatValidationMessage(message, errors),
|
||||
RateLimitFailure(:final message, :final retryAfter) => _formatRateLimitMessage(message, retryAfter),
|
||||
NoInternetFailure() => 'Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.',
|
||||
ValidationFailure(:final message, :final errors) =>
|
||||
_formatValidationMessage(message, errors),
|
||||
RateLimitFailure(:final message, :final retryAfter) =>
|
||||
_formatRateLimitMessage(message, retryAfter),
|
||||
NoInternetFailure() =>
|
||||
'Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.',
|
||||
TimeoutFailure() => 'Kết nối quá lâu. Vui lòng thử lại.',
|
||||
_ => message,
|
||||
};
|
||||
}
|
||||
|
||||
String _formatValidationMessage(String message, Map<String, List<String>>? errors) {
|
||||
String _formatValidationMessage(
|
||||
String message,
|
||||
Map<String, List<String>>? errors,
|
||||
) {
|
||||
if (errors != null && errors.isNotEmpty) {
|
||||
final firstError = errors.values.first.first;
|
||||
return '$message: $firstError';
|
||||
@@ -146,10 +134,7 @@ sealed class Failure {
|
||||
|
||||
/// Network-related failure
|
||||
final class NetworkFailure extends Failure {
|
||||
const NetworkFailure({
|
||||
required super.message,
|
||||
this.statusCode,
|
||||
});
|
||||
const NetworkFailure({required super.message, this.statusCode});
|
||||
|
||||
@override
|
||||
final int? statusCode;
|
||||
@@ -157,10 +142,7 @@ final class NetworkFailure extends Failure {
|
||||
|
||||
/// Server error failure (5xx errors)
|
||||
final class ServerFailure extends Failure {
|
||||
const ServerFailure({
|
||||
required super.message,
|
||||
this.statusCode,
|
||||
});
|
||||
const ServerFailure({required super.message, this.statusCode});
|
||||
|
||||
@override
|
||||
final int? statusCode;
|
||||
@@ -168,10 +150,7 @@ final class ServerFailure extends Failure {
|
||||
|
||||
/// Authentication failure
|
||||
final class AuthenticationFailure extends Failure {
|
||||
const AuthenticationFailure({
|
||||
required super.message,
|
||||
this.statusCode,
|
||||
});
|
||||
const AuthenticationFailure({required super.message, this.statusCode});
|
||||
|
||||
@override
|
||||
final int? statusCode;
|
||||
@@ -179,84 +158,61 @@ final class AuthenticationFailure extends Failure {
|
||||
|
||||
/// Validation failure
|
||||
final class ValidationFailure extends Failure {
|
||||
const ValidationFailure({
|
||||
required super.message,
|
||||
this.errors,
|
||||
});
|
||||
const ValidationFailure({required super.message, this.errors});
|
||||
|
||||
final Map<String, List<String>>? errors;
|
||||
}
|
||||
|
||||
/// Not found failure (404)
|
||||
final class NotFoundFailure extends Failure {
|
||||
const NotFoundFailure({
|
||||
required super.message,
|
||||
});
|
||||
const NotFoundFailure({required super.message});
|
||||
}
|
||||
|
||||
/// Conflict failure (409)
|
||||
final class ConflictFailure extends Failure {
|
||||
const ConflictFailure({
|
||||
required super.message,
|
||||
});
|
||||
const ConflictFailure({required super.message});
|
||||
}
|
||||
|
||||
/// Rate limit exceeded failure (429)
|
||||
final class RateLimitFailure extends Failure {
|
||||
const RateLimitFailure({
|
||||
required super.message,
|
||||
this.retryAfter,
|
||||
});
|
||||
const RateLimitFailure({required super.message, this.retryAfter});
|
||||
|
||||
final int? retryAfter;
|
||||
}
|
||||
|
||||
/// Payment failure
|
||||
final class PaymentFailure extends Failure {
|
||||
const PaymentFailure({
|
||||
required super.message,
|
||||
this.transactionId,
|
||||
});
|
||||
const PaymentFailure({required super.message, this.transactionId});
|
||||
|
||||
final String? transactionId;
|
||||
}
|
||||
|
||||
/// Cache failure
|
||||
final class CacheFailure extends Failure {
|
||||
const CacheFailure({
|
||||
required super.message,
|
||||
});
|
||||
const CacheFailure({required super.message});
|
||||
}
|
||||
|
||||
/// Storage failure
|
||||
final class StorageFailure extends Failure {
|
||||
const StorageFailure({
|
||||
required super.message,
|
||||
});
|
||||
const StorageFailure({required super.message});
|
||||
}
|
||||
|
||||
/// Parse failure
|
||||
final class ParseFailure extends Failure {
|
||||
const ParseFailure({
|
||||
required super.message,
|
||||
});
|
||||
const ParseFailure({required super.message});
|
||||
}
|
||||
|
||||
/// No internet connection failure
|
||||
final class NoInternetFailure extends Failure {
|
||||
const NoInternetFailure()
|
||||
: super(message: 'Không có kết nối internet');
|
||||
const NoInternetFailure() : super(message: 'Không có kết nối internet');
|
||||
}
|
||||
|
||||
/// Timeout failure
|
||||
final class TimeoutFailure extends Failure {
|
||||
const TimeoutFailure()
|
||||
: super(message: 'Kết nối quá lâu');
|
||||
const TimeoutFailure() : super(message: 'Kết nối quá lâu');
|
||||
}
|
||||
|
||||
/// Unknown failure
|
||||
final class UnknownFailure extends Failure {
|
||||
const UnknownFailure({
|
||||
required super.message,
|
||||
});
|
||||
const UnknownFailure({required super.message});
|
||||
}
|
||||
|
||||
@@ -10,11 +10,12 @@ library;
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
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';
|
||||
|
||||
@@ -23,6 +24,7 @@ part 'api_interceptor.g.dart';
|
||||
// ============================================================================
|
||||
|
||||
/// Keys for storing auth tokens in SharedPreferences
|
||||
/// @deprecated Use AuthLocalDataSource with Hive instead
|
||||
class AuthStorageKeys {
|
||||
static const String accessToken = 'auth_access_token';
|
||||
static const String refreshToken = 'auth_refresh_token';
|
||||
@@ -33,12 +35,15 @@ class AuthStorageKeys {
|
||||
// Auth Interceptor
|
||||
// ============================================================================
|
||||
|
||||
/// Interceptor for adding authentication tokens to requests
|
||||
/// Interceptor for adding ERPNext session tokens to requests
|
||||
///
|
||||
/// Adds SID (Session ID) and CSRF token from Hive storage to request headers.
|
||||
class AuthInterceptor extends Interceptor {
|
||||
AuthInterceptor(this._prefs, this._dio);
|
||||
AuthInterceptor(this._prefs, this._dio, this._authLocalDataSource);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
final Dio _dio;
|
||||
final AuthLocalDataSource _authLocalDataSource;
|
||||
|
||||
@override
|
||||
void onRequest(
|
||||
@@ -47,10 +52,19 @@ class AuthInterceptor extends Interceptor {
|
||||
) async {
|
||||
// Check if this endpoint requires authentication
|
||||
if (_requiresAuth(options.path)) {
|
||||
final token = await _getAccessToken();
|
||||
// Get session data from secure storage (async)
|
||||
final sid = await _authLocalDataSource.getSid();
|
||||
final csrfToken = await _authLocalDataSource.getCsrfToken();
|
||||
|
||||
if (sid != null && csrfToken != null) {
|
||||
// Add ERPNext session headers
|
||||
options.headers['Cookie'] = 'sid=$sid';
|
||||
options.headers['X-Frappe-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
// Legacy: Also check for access token (for backward compatibility)
|
||||
final token = await _getAccessToken();
|
||||
if (token != null) {
|
||||
// Add bearer token to headers
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
}
|
||||
@@ -66,10 +80,7 @@ class AuthInterceptor extends Interceptor {
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
// Check if error is 401 Unauthorized
|
||||
if (err.response?.statusCode == 401) {
|
||||
// Try to refresh token
|
||||
@@ -113,15 +124,16 @@ class AuthInterceptor extends Interceptor {
|
||||
}
|
||||
|
||||
/// Check if token is expired
|
||||
Future<bool> _isTokenExpired() async {
|
||||
final expiryString = _prefs.getString(AuthStorageKeys.tokenExpiry);
|
||||
if (expiryString == null) return true;
|
||||
|
||||
final expiry = DateTime.tryParse(expiryString);
|
||||
if (expiry == null) return true;
|
||||
|
||||
return DateTime.now().isAfter(expiry);
|
||||
}
|
||||
// TODO: Use this method when implementing token refresh logic
|
||||
// Future<bool> _isTokenExpired() async {
|
||||
// final expiryString = _prefs.getString(AuthStorageKeys.tokenExpiry);
|
||||
// if (expiryString == null) return true;
|
||||
//
|
||||
// final expiry = DateTime.tryParse(expiryString);
|
||||
// if (expiry == null) return true;
|
||||
//
|
||||
// return DateTime.now().isAfter(expiry);
|
||||
// }
|
||||
|
||||
/// Refresh access token using refresh token
|
||||
Future<bool> _refreshAccessToken() async {
|
||||
@@ -135,11 +147,7 @@ class AuthInterceptor extends Interceptor {
|
||||
// Call refresh token endpoint
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
'${ApiConstants.apiBaseUrl}${ApiConstants.refreshToken}',
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': 'Bearer $refreshToken',
|
||||
},
|
||||
),
|
||||
options: Options(headers: {'Authorization': 'Bearer $refreshToken'}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
@@ -185,10 +193,7 @@ class AuthInterceptor extends Interceptor {
|
||||
|
||||
final options = Options(
|
||||
method: requestOptions.method,
|
||||
headers: {
|
||||
...requestOptions.headers,
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
headers: {...requestOptions.headers, 'Authorization': 'Bearer $token'},
|
||||
);
|
||||
|
||||
return _dio.request(
|
||||
@@ -217,19 +222,13 @@ class LoggingInterceptor extends Interceptor {
|
||||
final bool enableErrorLogging;
|
||||
|
||||
@override
|
||||
void onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) {
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
if (enableRequestLogging) {
|
||||
developer.log(
|
||||
'╔══════════════════════════════════════════════════════════════',
|
||||
name: 'HTTP Request',
|
||||
);
|
||||
developer.log(
|
||||
'║ ${options.method} ${options.uri}',
|
||||
name: 'HTTP Request',
|
||||
);
|
||||
developer.log('║ ${options.method} ${options.uri}', name: 'HTTP Request');
|
||||
developer.log(
|
||||
'║ Headers: ${_sanitizeHeaders(options.headers)}',
|
||||
name: 'HTTP Request',
|
||||
@@ -290,10 +289,7 @@ class LoggingInterceptor extends Interceptor {
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) {
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (enableErrorLogging) {
|
||||
developer.log(
|
||||
'╔══════════════════════════════════════════════════════════════',
|
||||
@@ -303,18 +299,12 @@ class LoggingInterceptor extends Interceptor {
|
||||
'║ ${err.requestOptions.method} ${err.requestOptions.uri}',
|
||||
name: 'HTTP Error',
|
||||
);
|
||||
developer.log(
|
||||
'║ Error Type: ${err.type}',
|
||||
name: 'HTTP Error',
|
||||
);
|
||||
developer.log('║ Error Type: ${err.type}', name: 'HTTP Error');
|
||||
developer.log(
|
||||
'║ Status Code: ${err.response?.statusCode}',
|
||||
name: 'HTTP Error',
|
||||
);
|
||||
developer.log(
|
||||
'║ Message: ${err.message}',
|
||||
name: 'HTTP Error',
|
||||
);
|
||||
developer.log('║ Message: ${err.message}', name: 'HTTP Error');
|
||||
|
||||
if (err.response?.data != null) {
|
||||
developer.log(
|
||||
@@ -389,10 +379,7 @@ class LoggingInterceptor extends Interceptor {
|
||||
/// Interceptor for transforming Dio errors into custom exceptions
|
||||
class ErrorTransformerInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) {
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
Exception exception;
|
||||
|
||||
switch (err.type) {
|
||||
@@ -415,9 +402,7 @@ class ErrorTransformerInterceptor extends Interceptor {
|
||||
break;
|
||||
|
||||
case DioExceptionType.unknown:
|
||||
exception = NetworkException(
|
||||
'Lỗi không xác định: ${err.message}',
|
||||
);
|
||||
exception = NetworkException('Lỗi không xác định: ${err.message}');
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -447,7 +432,8 @@ class ErrorTransformerInterceptor extends Interceptor {
|
||||
// Extract error message from response
|
||||
String? message;
|
||||
if (data is Map<String, dynamic>) {
|
||||
message = data['message'] as String? ??
|
||||
message =
|
||||
data['message'] as String? ??
|
||||
data['error'] as String? ??
|
||||
data['msg'] as String?;
|
||||
}
|
||||
@@ -460,9 +446,7 @@ class ErrorTransformerInterceptor extends Interceptor {
|
||||
final validationErrors = errors.map(
|
||||
(key, value) => MapEntry(
|
||||
key,
|
||||
value is List
|
||||
? value.cast<String>()
|
||||
: [value.toString()],
|
||||
value is List ? value.cast<String>() : [value.toString()],
|
||||
),
|
||||
);
|
||||
return ValidationException(
|
||||
@@ -498,9 +482,7 @@ class ErrorTransformerInterceptor extends Interceptor {
|
||||
final validationErrors = errors.map(
|
||||
(key, value) => MapEntry(
|
||||
key,
|
||||
value is List
|
||||
? value.cast<String>()
|
||||
: [value.toString()],
|
||||
value is List ? value.cast<String>() : [value.toString()],
|
||||
),
|
||||
);
|
||||
return ValidationException(
|
||||
@@ -513,7 +495,9 @@ class ErrorTransformerInterceptor extends Interceptor {
|
||||
|
||||
case 429:
|
||||
final retryAfter = response.headers.value('retry-after');
|
||||
final retrySeconds = retryAfter != null ? int.tryParse(retryAfter) : null;
|
||||
final retrySeconds = retryAfter != null
|
||||
? int.tryParse(retryAfter)
|
||||
: null;
|
||||
return RateLimitException(message ?? 'Quá nhiều yêu cầu', retrySeconds);
|
||||
|
||||
case 500:
|
||||
@@ -549,7 +533,15 @@ Future<SharedPreferences> sharedPreferences(Ref ref) async {
|
||||
@riverpod
|
||||
Future<AuthInterceptor> authInterceptor(Ref ref, Dio dio) async {
|
||||
final prefs = await ref.watch(sharedPreferencesProvider.future);
|
||||
return AuthInterceptor(prefs, dio);
|
||||
|
||||
// Create AuthLocalDataSource with FlutterSecureStorage
|
||||
const secureStorage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
final authLocalDataSource = AuthLocalDataSource(secureStorage);
|
||||
|
||||
return AuthInterceptor(prefs, dio, authLocalDataSource);
|
||||
}
|
||||
|
||||
/// Provider for LoggingInterceptor
|
||||
|
||||
@@ -114,7 +114,7 @@ final class AuthInterceptorProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$authInterceptorHash() => r'b54ba9af62c3cd7b922ef4030a8e2debb0220e10';
|
||||
String _$authInterceptorHash() => r'3f964536e03e204d09cc9120dd9d961b6d6d4b71';
|
||||
|
||||
/// Provider for AuthInterceptor
|
||||
|
||||
|
||||
@@ -215,14 +215,14 @@ class DioClient {
|
||||
/// Clear all cached responses
|
||||
Future<void> clearCache() async {
|
||||
if (_cacheStore != null) {
|
||||
await _cacheStore!.clean();
|
||||
await _cacheStore.clean();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear specific cached response by key
|
||||
Future<void> clearCacheByKey(String key) async {
|
||||
if (_cacheStore != null) {
|
||||
await _cacheStore!.delete(key);
|
||||
await _cacheStore.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ class DioClient {
|
||||
final key = CacheOptions.defaultCacheKeyBuilder(
|
||||
RequestOptions(path: path),
|
||||
);
|
||||
await _cacheStore!.delete(key);
|
||||
await _cacheStore.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,10 +258,7 @@ class RetryInterceptor extends Interceptor {
|
||||
final double delayMultiplier;
|
||||
|
||||
@override
|
||||
void onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
// Get retry count from request extra
|
||||
final retries = err.requestOptions.extra['retries'] as int? ?? 0;
|
||||
|
||||
@@ -279,8 +276,9 @@ class RetryInterceptor extends Interceptor {
|
||||
}
|
||||
|
||||
// Calculate delay with exponential backoff
|
||||
final delayMs = (initialDelay.inMilliseconds *
|
||||
(delayMultiplier * (retries + 1))).toInt();
|
||||
final delayMs =
|
||||
(initialDelay.inMilliseconds * (delayMultiplier * (retries + 1)))
|
||||
.toInt();
|
||||
final delay = Duration(
|
||||
milliseconds: delayMs.clamp(
|
||||
initialDelay.inMilliseconds,
|
||||
@@ -341,10 +339,7 @@ class RetryInterceptor extends Interceptor {
|
||||
@riverpod
|
||||
Future<CacheStore> cacheStore(Ref ref) async {
|
||||
final directory = await getTemporaryDirectory();
|
||||
return HiveCacheStore(
|
||||
directory.path,
|
||||
hiveBoxName: 'dio_cache',
|
||||
);
|
||||
return HiveCacheStore(directory.path, hiveBoxName: 'dio_cache');
|
||||
}
|
||||
|
||||
/// Provider for cache options
|
||||
@@ -371,31 +366,32 @@ Future<Dio> dio(Ref ref) async {
|
||||
// Base configuration
|
||||
dio
|
||||
..options = BaseOptions(
|
||||
baseUrl: ApiConstants.apiBaseUrl,
|
||||
connectTimeout: ApiConstants.connectionTimeout,
|
||||
receiveTimeout: ApiConstants.receiveTimeout,
|
||||
sendTimeout: ApiConstants.sendTimeout,
|
||||
headers: {
|
||||
'Content-Type': ApiConstants.contentTypeJson,
|
||||
'Accept': ApiConstants.acceptJson,
|
||||
'Accept-Language': ApiConstants.acceptLanguageVi,
|
||||
},
|
||||
responseType: ResponseType.json,
|
||||
validateStatus: (status) {
|
||||
// Accept all status codes and handle errors in interceptor
|
||||
return status != null && status < 500;
|
||||
},
|
||||
)
|
||||
|
||||
// Add interceptors in order
|
||||
|
||||
baseUrl: ApiConstants.apiBaseUrl,
|
||||
connectTimeout: ApiConstants.connectionTimeout,
|
||||
receiveTimeout: ApiConstants.receiveTimeout,
|
||||
sendTimeout: ApiConstants.sendTimeout,
|
||||
headers: {
|
||||
'Content-Type': ApiConstants.contentTypeJson,
|
||||
'Accept': ApiConstants.acceptJson,
|
||||
'Accept-Language': ApiConstants.acceptLanguageVi,
|
||||
},
|
||||
responseType: ResponseType.json,
|
||||
validateStatus: (status) {
|
||||
// Accept all status codes and handle errors in interceptor
|
||||
return status != null && status < 500;
|
||||
},
|
||||
)
|
||||
// Add interceptors in order
|
||||
// 1. Logging interceptor (first to log everything)
|
||||
..interceptors.add(ref.watch(loggingInterceptorProvider))
|
||||
|
||||
// 2. Auth interceptor (add tokens to requests)
|
||||
..interceptors.add(await ref.watch(authInterceptorProvider(dio).future))
|
||||
// 3. Cache interceptor
|
||||
..interceptors.add(DioCacheInterceptor(options: await ref.watch(cacheOptionsProvider.future)))
|
||||
..interceptors.add(
|
||||
DioCacheInterceptor(
|
||||
options: await ref.watch(cacheOptionsProvider.future),
|
||||
),
|
||||
)
|
||||
// 4. Retry interceptor
|
||||
..interceptors.add(RetryInterceptor(ref.watch(networkInfoProvider)))
|
||||
// 5. Error transformer (last to transform all errors)
|
||||
@@ -430,9 +426,7 @@ class ApiRequestOptions {
|
||||
final bool forceRefresh;
|
||||
|
||||
/// Options with cache enabled
|
||||
static const cached = ApiRequestOptions(
|
||||
cachePolicy: CachePolicy.forceCache,
|
||||
);
|
||||
static const cached = ApiRequestOptions(cachePolicy: CachePolicy.forceCache);
|
||||
|
||||
/// Options with network-first strategy
|
||||
static const networkFirst = ApiRequestOptions(
|
||||
@@ -449,12 +443,9 @@ class ApiRequestOptions {
|
||||
Options toDioOptions() {
|
||||
return Options(
|
||||
extra: <String, dynamic>{
|
||||
if (cachePolicy != null)
|
||||
CacheResponse.cacheKey: cachePolicy!.index,
|
||||
if (cacheDuration != null)
|
||||
'maxStale': cacheDuration,
|
||||
if (forceRefresh)
|
||||
'policy': CachePolicy.refresh.index,
|
||||
if (cachePolicy != null) CacheResponse.cacheKey: cachePolicy!.index,
|
||||
if (cacheDuration != null) 'maxStale': cacheDuration,
|
||||
if (forceRefresh) 'policy': CachePolicy.refresh.index,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -487,10 +478,10 @@ class QueuedRequest {
|
||||
final DateTime timestamp;
|
||||
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'method': method,
|
||||
'path': path,
|
||||
'data': data,
|
||||
'queryParameters': queryParameters,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
'method': method,
|
||||
'path': path,
|
||||
'data': data,
|
||||
'queryParameters': queryParameters,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,7 +191,9 @@ class NetworkInfoImpl implements NetworkInfo {
|
||||
return !results.contains(ConnectivityResult.none);
|
||||
}
|
||||
|
||||
NetworkConnectionType _mapConnectivityResult(List<ConnectivityResult> results) {
|
||||
NetworkConnectionType _mapConnectivityResult(
|
||||
List<ConnectivityResult> results,
|
||||
) {
|
||||
if (results.isEmpty || results.contains(ConnectivityResult.none)) {
|
||||
return NetworkConnectionType.none;
|
||||
}
|
||||
@@ -273,14 +275,11 @@ class NetworkStatusNotifier extends _$NetworkStatusNotifier {
|
||||
final status = await networkInfo.networkStatus;
|
||||
|
||||
// Listen to network changes
|
||||
ref.listen(
|
||||
networkStatusStreamProvider,
|
||||
(_, next) {
|
||||
next.whenData((newStatus) {
|
||||
state = AsyncValue.data(newStatus);
|
||||
});
|
||||
},
|
||||
);
|
||||
ref.listen(networkStatusStreamProvider, (_, next) {
|
||||
next.whenData((newStatus) {
|
||||
state = AsyncValue.data(newStatus);
|
||||
});
|
||||
});
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ Stream<bool> isOnline(Ref ref) {
|
||||
return connectivity.onConnectivityChanged.map((result) {
|
||||
// Online if connected to WiFi or mobile
|
||||
return result.contains(ConnectivityResult.wifi) ||
|
||||
result.contains(ConnectivityResult.mobile);
|
||||
result.contains(ConnectivityResult.mobile);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,11 @@ String appVersion(Ref ref) {
|
||||
int pointsMultiplier(Ref ref) {
|
||||
// Can read other providers
|
||||
final userTier = 'diamond'; // This would come from another provider
|
||||
return userTier == 'diamond' ? 3 : userTier == 'platinum' ? 2 : 1;
|
||||
return userTier == 'diamond'
|
||||
? 3
|
||||
: userTier == 'platinum'
|
||||
? 2
|
||||
: 1;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -46,7 +50,7 @@ int pointsMultiplier(Ref ref) {
|
||||
@riverpod
|
||||
Future<String> userData(Ref ref) async {
|
||||
// Simulate API call
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
return 'User Data';
|
||||
}
|
||||
|
||||
@@ -55,7 +59,7 @@ Future<String> userData(Ref ref) async {
|
||||
@riverpod
|
||||
Future<String> userProfile(Ref ref, String userId) async {
|
||||
// Simulate API call with userId
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
return 'Profile for user: $userId';
|
||||
}
|
||||
|
||||
@@ -70,7 +74,7 @@ Future<List<String>> productList(
|
||||
String? searchQuery,
|
||||
}) async {
|
||||
// Simulate API call with parameters
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
return ['Product 1', 'Product 2', 'Product 3'];
|
||||
}
|
||||
|
||||
@@ -82,10 +86,7 @@ Future<List<String>> productList(
|
||||
/// Use this for WebSocket connections, real-time updates, etc.
|
||||
@riverpod
|
||||
Stream<int> timer(Ref ref) {
|
||||
return Stream.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(count) => count,
|
||||
);
|
||||
return Stream.periodic(const Duration(seconds: 1), (count) => count);
|
||||
}
|
||||
|
||||
/// Stream provider with parameters
|
||||
@@ -176,7 +177,7 @@ class UserProfileNotifier extends _$UserProfileNotifier {
|
||||
@override
|
||||
Future<UserProfileData> build() async {
|
||||
// Fetch initial data
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
return UserProfileData(name: 'John Doe', email: 'john@example.com');
|
||||
}
|
||||
|
||||
@@ -225,10 +226,7 @@ class UserProfileData {
|
||||
final String email;
|
||||
|
||||
UserProfileData copyWith({String? name, String? email}) {
|
||||
return UserProfileData(
|
||||
name: name ?? this.name,
|
||||
email: email ?? this.email,
|
||||
);
|
||||
return UserProfileData(name: name ?? this.name, email: email ?? this.email);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ final class UserDataProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$userDataHash() => r'3df905d6ea9f81ce7ca8205bd785ad4d4376b399';
|
||||
String _$userDataHash() => r'1b754e931a5d4c202189fcdd3de54815f93aaba2';
|
||||
|
||||
/// Async provider with parameters (Family pattern)
|
||||
/// Parameters are just function parameters - much simpler than before!
|
||||
@@ -248,7 +248,7 @@ final class UserProfileProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$userProfileHash() => r'd42ed517f41ce0dfde58d74b2beb3d8415b81a22';
|
||||
String _$userProfileHash() => r'35cdc3f9117e81c0150399d7015840ed034661d3';
|
||||
|
||||
/// Async provider with parameters (Family pattern)
|
||||
/// Parameters are just function parameters - much simpler than before!
|
||||
@@ -346,7 +346,7 @@ final class ProductListProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$productListHash() => r'aacee7761543692ccd59f0ecd2f290e1a7de203a';
|
||||
String _$productListHash() => r'db1568fb33a3615db0a31265c953d6db62b6535b';
|
||||
|
||||
/// Async provider with multiple parameters
|
||||
/// Named parameters, optional parameters, defaults - all supported!
|
||||
@@ -732,7 +732,7 @@ final class UserProfileNotifierProvider
|
||||
}
|
||||
|
||||
String _$userProfileNotifierHash() =>
|
||||
r'87c9a9277552095a0ed0b768829e2930fa475c7f';
|
||||
r'be7bcbe81f84be6ef50e94f52e0c65b00230291c';
|
||||
|
||||
/// AsyncNotifier for state that requires async initialization
|
||||
/// Perfect for fetching data that can then be modified
|
||||
|
||||
@@ -10,6 +10,8 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:worker/features/account/presentation/pages/addresses_page.dart';
|
||||
import 'package:worker/features/account/presentation/pages/change_password_page.dart';
|
||||
import 'package:worker/features/account/presentation/pages/profile_edit_page.dart';
|
||||
import 'package:worker/features/auth/presentation/pages/login_page.dart';
|
||||
import 'package:worker/features/auth/presentation/pages/register_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';
|
||||
@@ -46,10 +48,24 @@ class AppRouter {
|
||||
/// Router configuration
|
||||
static final GoRouter router = GoRouter(
|
||||
// Initial route
|
||||
initialLocation: RouteNames.home,
|
||||
initialLocation: RouteNames.login,
|
||||
|
||||
// Route definitions
|
||||
routes: [
|
||||
// Authentication Routes
|
||||
GoRoute(
|
||||
path: RouteNames.login,
|
||||
name: RouteNames.login,
|
||||
pageBuilder: (context, state) =>
|
||||
MaterialPage(key: state.pageKey, child: const LoginPage()),
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteNames.register,
|
||||
name: RouteNames.register,
|
||||
pageBuilder: (context, state) =>
|
||||
MaterialPage(key: state.pageKey, child: const RegisterPage()),
|
||||
),
|
||||
|
||||
// Main Route (with bottom navigation)
|
||||
GoRoute(
|
||||
path: RouteNames.home,
|
||||
@@ -278,8 +294,10 @@ class AppRouter {
|
||||
GoRoute(
|
||||
path: RouteNames.designRequestCreate,
|
||||
name: RouteNames.designRequestCreate,
|
||||
pageBuilder: (context, state) =>
|
||||
MaterialPage(key: state.pageKey, child: const DesignRequestCreatePage()),
|
||||
pageBuilder: (context, state) => MaterialPage(
|
||||
key: state.pageKey,
|
||||
child: const DesignRequestCreatePage(),
|
||||
),
|
||||
),
|
||||
|
||||
// Design Request Detail Route
|
||||
@@ -421,7 +439,8 @@ class RouteNames {
|
||||
|
||||
// Model Houses & Design Requests Routes
|
||||
static const String modelHouses = '/model-houses';
|
||||
static const String designRequestCreate = '/model-houses/design-request/create';
|
||||
static const String designRequestCreate =
|
||||
'/model-houses/design-request/create';
|
||||
static const String designRequestDetail = '/model-houses/design-request/:id';
|
||||
|
||||
// Authentication Routes (TODO: implement when auth feature is ready)
|
||||
@@ -434,6 +453,12 @@ class RouteNames {
|
||||
///
|
||||
/// Helper extensions for common navigation patterns.
|
||||
extension GoRouterExtension on BuildContext {
|
||||
/// Navigate to login page
|
||||
void goLogin() => go(RouteNames.login);
|
||||
|
||||
/// Navigate to register page
|
||||
void goRegister() => go(RouteNames.register);
|
||||
|
||||
/// Navigate to home page
|
||||
void goHome() => go(RouteNames.home);
|
||||
|
||||
|
||||
@@ -40,10 +40,7 @@ class AppTheme {
|
||||
color: AppColors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
iconTheme: const IconThemeData(
|
||||
color: AppColors.white,
|
||||
size: 24,
|
||||
),
|
||||
iconTheme: const IconThemeData(color: AppColors.white, size: 24),
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
),
|
||||
|
||||
@@ -65,9 +62,7 @@ class AppTheme {
|
||||
foregroundColor: AppColors.white,
|
||||
elevation: 2,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
textStyle: AppTypography.buttonText,
|
||||
minimumSize: const Size(64, 48),
|
||||
),
|
||||
@@ -78,9 +73,7 @@ class AppTheme {
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryBlue,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
textStyle: AppTypography.buttonText,
|
||||
),
|
||||
),
|
||||
@@ -91,9 +84,7 @@ class AppTheme {
|
||||
foregroundColor: AppColors.primaryBlue,
|
||||
side: const BorderSide(color: AppColors.primaryBlue, width: 1.5),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
textStyle: AppTypography.buttonText,
|
||||
minimumSize: const Size(64, 48),
|
||||
),
|
||||
@@ -103,7 +94,10 @@ class AppTheme {
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.white,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1),
|
||||
@@ -124,15 +118,9 @@ class AppTheme {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.danger, width: 2),
|
||||
),
|
||||
labelStyle: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
hintStyle: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
errorStyle: AppTypography.bodySmall.copyWith(
|
||||
color: AppColors.danger,
|
||||
),
|
||||
labelStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
|
||||
hintStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
|
||||
errorStyle: AppTypography.bodySmall.copyWith(color: AppColors.danger),
|
||||
),
|
||||
|
||||
// ==================== Bottom Navigation Bar Theme ====================
|
||||
@@ -144,10 +132,7 @@ class AppTheme {
|
||||
size: 28,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
unselectedIconTheme: IconThemeData(
|
||||
size: 24,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
unselectedIconTheme: IconThemeData(size: 24, color: AppColors.grey500),
|
||||
selectedLabelStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -182,26 +167,25 @@ class AppTheme {
|
||||
color: AppColors.white,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
|
||||
// ==================== Dialog Theme ====================
|
||||
dialogTheme: const DialogThemeData(
|
||||
backgroundColor: AppColors.white,
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
),
|
||||
).copyWith(
|
||||
titleTextStyle: AppTypography.headlineMedium.copyWith(
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
contentTextStyle: AppTypography.bodyLarge.copyWith(
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
dialogTheme:
|
||||
const DialogThemeData(
|
||||
backgroundColor: AppColors.white,
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
),
|
||||
).copyWith(
|
||||
titleTextStyle: AppTypography.headlineMedium.copyWith(
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
contentTextStyle: AppTypography.bodyLarge.copyWith(
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
|
||||
// ==================== Snackbar Theme ====================
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
@@ -209,9 +193,7 @@ class AppTheme {
|
||||
contentTextStyle: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.white,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
elevation: 4,
|
||||
),
|
||||
@@ -224,10 +206,7 @@ class AppTheme {
|
||||
),
|
||||
|
||||
// ==================== Icon Theme ====================
|
||||
iconTheme: const IconThemeData(
|
||||
color: AppColors.grey900,
|
||||
size: 24,
|
||||
),
|
||||
iconTheme: const IconThemeData(color: AppColors.grey900, size: 24),
|
||||
|
||||
// ==================== List Tile Theme ====================
|
||||
listTileTheme: ListTileThemeData(
|
||||
@@ -266,9 +245,7 @@ class AppTheme {
|
||||
return AppColors.white;
|
||||
}),
|
||||
checkColor: MaterialStateProperty.all(AppColors.white),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
),
|
||||
|
||||
// ==================== Radio Theme ====================
|
||||
@@ -297,14 +274,15 @@ class AppTheme {
|
||||
),
|
||||
|
||||
// ==================== Tab Bar Theme ====================
|
||||
tabBarTheme: const TabBarThemeData(
|
||||
labelColor: AppColors.primaryBlue,
|
||||
unselectedLabelColor: AppColors.grey500,
|
||||
indicatorColor: AppColors.primaryBlue,
|
||||
).copyWith(
|
||||
labelStyle: AppTypography.labelLarge,
|
||||
unselectedLabelStyle: AppTypography.labelLarge,
|
||||
),
|
||||
tabBarTheme:
|
||||
const TabBarThemeData(
|
||||
labelColor: AppColors.primaryBlue,
|
||||
unselectedLabelColor: AppColors.grey500,
|
||||
indicatorColor: AppColors.primaryBlue,
|
||||
).copyWith(
|
||||
labelStyle: AppTypography.labelLarge,
|
||||
unselectedLabelStyle: AppTypography.labelLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -338,10 +316,7 @@ class AppTheme {
|
||||
color: AppColors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
iconTheme: const IconThemeData(
|
||||
color: AppColors.white,
|
||||
size: 24,
|
||||
),
|
||||
iconTheme: const IconThemeData(color: AppColors.white, size: 24),
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
),
|
||||
|
||||
@@ -363,9 +338,7 @@ class AppTheme {
|
||||
foregroundColor: AppColors.white,
|
||||
elevation: 2,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
textStyle: AppTypography.buttonText,
|
||||
minimumSize: const Size(64, 48),
|
||||
),
|
||||
@@ -375,7 +348,10 @@ class AppTheme {
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF2A2A2A),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFF3A3A3A), width: 1),
|
||||
@@ -396,15 +372,9 @@ class AppTheme {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.danger, width: 2),
|
||||
),
|
||||
labelStyle: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
hintStyle: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
errorStyle: AppTypography.bodySmall.copyWith(
|
||||
color: AppColors.danger,
|
||||
),
|
||||
labelStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
|
||||
hintStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
|
||||
errorStyle: AppTypography.bodySmall.copyWith(color: AppColors.danger),
|
||||
),
|
||||
|
||||
// ==================== Bottom Navigation Bar Theme ====================
|
||||
@@ -412,14 +382,8 @@ class AppTheme {
|
||||
backgroundColor: Color(0xFF1E1E1E),
|
||||
selectedItemColor: AppColors.lightBlue,
|
||||
unselectedItemColor: AppColors.grey500,
|
||||
selectedIconTheme: IconThemeData(
|
||||
size: 28,
|
||||
color: AppColors.lightBlue,
|
||||
),
|
||||
unselectedIconTheme: IconThemeData(
|
||||
size: 24,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
selectedIconTheme: IconThemeData(size: 28, color: AppColors.lightBlue),
|
||||
unselectedIconTheme: IconThemeData(size: 24, color: AppColors.grey500),
|
||||
selectedLabelStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -449,9 +413,7 @@ class AppTheme {
|
||||
contentTextStyle: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.white,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
elevation: 4,
|
||||
),
|
||||
|
||||
@@ -423,16 +423,14 @@ extension BuildContextExtensions on BuildContext {
|
||||
|
||||
/// Navigate to route
|
||||
Future<T?> push<T>(Widget page) {
|
||||
return Navigator.of(this).push<T>(
|
||||
MaterialPageRoute(builder: (_) => page),
|
||||
);
|
||||
return Navigator.of(this).push<T>(MaterialPageRoute(builder: (_) => page));
|
||||
}
|
||||
|
||||
/// Navigate and replace current route
|
||||
Future<T?> pushReplacement<T>(Widget page) {
|
||||
return Navigator.of(this).pushReplacement<T, void>(
|
||||
MaterialPageRoute(builder: (_) => page),
|
||||
);
|
||||
return Navigator.of(
|
||||
this,
|
||||
).pushReplacement<T, void>(MaterialPageRoute(builder: (_) => page));
|
||||
}
|
||||
|
||||
/// Pop current route
|
||||
|
||||
@@ -313,15 +313,21 @@ class TextFormatter {
|
||||
}
|
||||
|
||||
/// Truncate text with ellipsis
|
||||
static String truncate(String text, int maxLength, {String ellipsis = '...'}) {
|
||||
static String truncate(
|
||||
String text,
|
||||
int maxLength, {
|
||||
String ellipsis = '...',
|
||||
}) {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - ellipsis.length) + ellipsis;
|
||||
}
|
||||
|
||||
/// Remove diacritics from Vietnamese text
|
||||
static String removeDiacritics(String text) {
|
||||
const withDiacritics = 'àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ';
|
||||
const withoutDiacritics = 'aaaaaaaaaaaaaaaaaeeeeeeeeeeeiiiiioooooooooooooooooouuuuuuuuuuuyyyyyd';
|
||||
const withDiacritics =
|
||||
'àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ';
|
||||
const withoutDiacritics =
|
||||
'aaaaaaaaaaaaaaaaaeeeeeeeeeeeiiiiioooooooooooooooooouuuuuuuuuuuyyyyyd';
|
||||
|
||||
var result = text.toLowerCase();
|
||||
for (var i = 0; i < withDiacritics.length; i++) {
|
||||
|
||||
@@ -241,8 +241,11 @@ class L10nHelper {
|
||||
/// final pointsText = L10nHelper.formatPoints(context, 100);
|
||||
/// // Returns: "+100 điểm" (Vietnamese) or "+100 points" (English)
|
||||
/// ```
|
||||
static String formatPoints(BuildContext context, int points,
|
||||
{bool showSign = true}) {
|
||||
static String formatPoints(
|
||||
BuildContext context,
|
||||
int points, {
|
||||
bool showSign = true,
|
||||
}) {
|
||||
if (showSign && points > 0) {
|
||||
return context.l10n.earnedPoints(points);
|
||||
} else if (showSign && points < 0) {
|
||||
|
||||
@@ -66,9 +66,7 @@ class QRGenerator {
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
gapless: true,
|
||||
embeddedImageStyle: const QrEmbeddedImageStyle(
|
||||
size: Size(48, 48),
|
||||
),
|
||||
embeddedImageStyle: const QrEmbeddedImageStyle(size: Size(48, 48)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,9 +187,7 @@ class QRGenerator {
|
||||
embeddedImage: embeddedImage is AssetImage
|
||||
? (embeddedImage as AssetImage).assetName as ImageProvider
|
||||
: null,
|
||||
embeddedImageStyle: QrEmbeddedImageStyle(
|
||||
size: embeddedImageSize,
|
||||
),
|
||||
embeddedImageStyle: QrEmbeddedImageStyle(size: embeddedImageSize),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,18 +199,12 @@ class QRGenerator {
|
||||
if (data.contains(':')) {
|
||||
final parts = data.split(':');
|
||||
if (parts.length == 2) {
|
||||
return {
|
||||
'type': parts[0].toUpperCase(),
|
||||
'value': parts[1],
|
||||
};
|
||||
return {'type': parts[0].toUpperCase(), 'value': parts[1]};
|
||||
}
|
||||
}
|
||||
|
||||
// If no type prefix, return as generic data
|
||||
return {
|
||||
'type': 'GENERIC',
|
||||
'value': data,
|
||||
};
|
||||
return {'type': 'GENERIC', 'value': data};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -244,9 +244,7 @@ class Validators {
|
||||
}
|
||||
|
||||
if (double.tryParse(value) == null) {
|
||||
return fieldName != null
|
||||
? '$fieldName phải là số'
|
||||
: 'Giá trị phải là số';
|
||||
return fieldName != null ? '$fieldName phải là số' : 'Giá trị phải là số';
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -351,7 +349,8 @@ class Validators {
|
||||
);
|
||||
|
||||
final today = DateTime.now();
|
||||
final age = today.year -
|
||||
final age =
|
||||
today.year -
|
||||
birthDate.year -
|
||||
(today.month > birthDate.month ||
|
||||
(today.month == birthDate.month && today.day >= birthDate.day)
|
||||
@@ -456,11 +455,7 @@ class Validators {
|
||||
// ========================================================================
|
||||
|
||||
/// Validate against custom regex pattern
|
||||
static String? pattern(
|
||||
String? value,
|
||||
RegExp pattern,
|
||||
String errorMessage,
|
||||
) {
|
||||
static String? pattern(String? value, RegExp pattern, String errorMessage) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Trường này là bắt buộc';
|
||||
}
|
||||
@@ -491,12 +486,7 @@ class Validators {
|
||||
}
|
||||
|
||||
/// Password strength enum
|
||||
enum PasswordStrength {
|
||||
weak,
|
||||
medium,
|
||||
strong,
|
||||
veryStrong,
|
||||
}
|
||||
enum PasswordStrength { weak, medium, strong, veryStrong }
|
||||
|
||||
/// Password strength calculator
|
||||
class PasswordStrengthCalculator {
|
||||
|
||||
@@ -58,10 +58,7 @@ class CustomBottomNavBar extends StatelessWidget {
|
||||
selectedFontSize: 12,
|
||||
unselectedFontSize: 12,
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.shopping_bag),
|
||||
label: 'Products',
|
||||
@@ -70,14 +67,8 @@ class CustomBottomNavBar extends StatelessWidget {
|
||||
icon: Icon(Icons.card_membership),
|
||||
label: 'Loyalty',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.person),
|
||||
label: 'Account',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.menu),
|
||||
label: 'More',
|
||||
),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Account'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.menu), label: 'More'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,10 +124,7 @@ class CustomButton extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -135,10 +132,7 @@ class CustomButton extends StatelessWidget {
|
||||
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,11 +54,7 @@ class EmptyState extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
Icon(icon, size: iconSize, color: AppColors.grey500),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
@@ -73,10 +69,7 @@ class EmptyState extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -58,15 +58,9 @@ class ChatFloatingButton extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.danger,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 20,
|
||||
minHeight: 20,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 20, minHeight: 20),
|
||||
child: Center(
|
||||
child: Text(
|
||||
unreadCount! > 99 ? '99+' : unreadCount.toString(),
|
||||
|
||||
@@ -50,10 +50,7 @@ class CustomLoadingIndicator extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -6,18 +6,39 @@ part 'audit_log_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.auditLogModel)
|
||||
class AuditLogModel extends HiveObject {
|
||||
AuditLogModel({required this.logId, required this.userId, required this.action, required this.entityType, required this.entityId, this.oldValue, this.newValue, this.ipAddress, this.userAgent, required this.timestamp});
|
||||
|
||||
@HiveField(0) final int logId;
|
||||
@HiveField(1) final String userId;
|
||||
@HiveField(2) final String action;
|
||||
@HiveField(3) final String entityType;
|
||||
@HiveField(4) final String entityId;
|
||||
@HiveField(5) final String? oldValue;
|
||||
@HiveField(6) final String? newValue;
|
||||
@HiveField(7) final String? ipAddress;
|
||||
@HiveField(8) final String? userAgent;
|
||||
@HiveField(9) final DateTime timestamp;
|
||||
AuditLogModel({
|
||||
required this.logId,
|
||||
required this.userId,
|
||||
required this.action,
|
||||
required this.entityType,
|
||||
required this.entityId,
|
||||
this.oldValue,
|
||||
this.newValue,
|
||||
this.ipAddress,
|
||||
this.userAgent,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
@HiveField(0)
|
||||
final int logId;
|
||||
@HiveField(1)
|
||||
final String userId;
|
||||
@HiveField(2)
|
||||
final String action;
|
||||
@HiveField(3)
|
||||
final String entityType;
|
||||
@HiveField(4)
|
||||
final String entityId;
|
||||
@HiveField(5)
|
||||
final String? oldValue;
|
||||
@HiveField(6)
|
||||
final String? newValue;
|
||||
@HiveField(7)
|
||||
final String? ipAddress;
|
||||
@HiveField(8)
|
||||
final String? userAgent;
|
||||
@HiveField(9)
|
||||
final DateTime timestamp;
|
||||
|
||||
factory AuditLogModel.fromJson(Map<String, dynamic> json) => AuditLogModel(
|
||||
logId: json['log_id'] as int,
|
||||
|
||||
@@ -6,33 +6,65 @@ part 'payment_reminder_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.paymentReminderModel)
|
||||
class PaymentReminderModel extends HiveObject {
|
||||
PaymentReminderModel({required this.reminderId, required this.invoiceId, required this.userId, required this.reminderType, required this.subject, required this.message, required this.isRead, required this.isSent, this.scheduledAt, this.sentAt, this.readAt});
|
||||
|
||||
@HiveField(0) final String reminderId;
|
||||
@HiveField(1) final String invoiceId;
|
||||
@HiveField(2) final String userId;
|
||||
@HiveField(3) final ReminderType reminderType;
|
||||
@HiveField(4) final String subject;
|
||||
@HiveField(5) final String message;
|
||||
@HiveField(6) final bool isRead;
|
||||
@HiveField(7) final bool isSent;
|
||||
@HiveField(8) final DateTime? scheduledAt;
|
||||
@HiveField(9) final DateTime? sentAt;
|
||||
@HiveField(10) final DateTime? readAt;
|
||||
PaymentReminderModel({
|
||||
required this.reminderId,
|
||||
required this.invoiceId,
|
||||
required this.userId,
|
||||
required this.reminderType,
|
||||
required this.subject,
|
||||
required this.message,
|
||||
required this.isRead,
|
||||
required this.isSent,
|
||||
this.scheduledAt,
|
||||
this.sentAt,
|
||||
this.readAt,
|
||||
});
|
||||
|
||||
factory PaymentReminderModel.fromJson(Map<String, dynamic> json) => PaymentReminderModel(
|
||||
reminderId: json['reminder_id'] as String,
|
||||
invoiceId: json['invoice_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
reminderType: ReminderType.values.firstWhere((e) => e.name == json['reminder_type']),
|
||||
subject: json['subject'] as String,
|
||||
message: json['message'] as String,
|
||||
isRead: json['is_read'] as bool? ?? false,
|
||||
isSent: json['is_sent'] as bool? ?? false,
|
||||
scheduledAt: json['scheduled_at'] != null ? DateTime.parse(json['scheduled_at']?.toString() ?? '') : null,
|
||||
sentAt: json['sent_at'] != null ? DateTime.parse(json['sent_at']?.toString() ?? '') : null,
|
||||
readAt: json['read_at'] != null ? DateTime.parse(json['read_at']?.toString() ?? '') : null,
|
||||
);
|
||||
@HiveField(0)
|
||||
final String reminderId;
|
||||
@HiveField(1)
|
||||
final String invoiceId;
|
||||
@HiveField(2)
|
||||
final String userId;
|
||||
@HiveField(3)
|
||||
final ReminderType reminderType;
|
||||
@HiveField(4)
|
||||
final String subject;
|
||||
@HiveField(5)
|
||||
final String message;
|
||||
@HiveField(6)
|
||||
final bool isRead;
|
||||
@HiveField(7)
|
||||
final bool isSent;
|
||||
@HiveField(8)
|
||||
final DateTime? scheduledAt;
|
||||
@HiveField(9)
|
||||
final DateTime? sentAt;
|
||||
@HiveField(10)
|
||||
final DateTime? readAt;
|
||||
|
||||
factory PaymentReminderModel.fromJson(Map<String, dynamic> json) =>
|
||||
PaymentReminderModel(
|
||||
reminderId: json['reminder_id'] as String,
|
||||
invoiceId: json['invoice_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
reminderType: ReminderType.values.firstWhere(
|
||||
(e) => e.name == json['reminder_type'],
|
||||
),
|
||||
subject: json['subject'] as String,
|
||||
message: json['message'] as String,
|
||||
isRead: json['is_read'] as bool? ?? false,
|
||||
isSent: json['is_sent'] as bool? ?? false,
|
||||
scheduledAt: json['scheduled_at'] != null
|
||||
? DateTime.parse(json['scheduled_at']?.toString() ?? '')
|
||||
: null,
|
||||
sentAt: json['sent_at'] != null
|
||||
? DateTime.parse(json['sent_at']?.toString() ?? '')
|
||||
: null,
|
||||
readAt: json['read_at'] != null
|
||||
? DateTime.parse(json['read_at']?.toString() ?? '')
|
||||
: null,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'reminder_id': reminderId,
|
||||
|
||||
@@ -134,13 +134,7 @@ class AuditLog {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
logId,
|
||||
userId,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
);
|
||||
return Object.hash(logId, userId, action, entityType, entityId);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -59,10 +59,7 @@ class AccountMenuItem extends StatelessWidget {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.grey100,
|
||||
width: 1.0,
|
||||
),
|
||||
bottom: BorderSide(color: AppColors.grey100, width: 1.0),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -72,7 +69,9 @@ class AccountMenuItem extends StatelessWidget {
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBackgroundColor ?? AppColors.lightBlue.withValues(alpha: 0.1),
|
||||
color:
|
||||
iconBackgroundColor ??
|
||||
AppColors.lightBlue.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
|
||||
122
lib/features/auth/data/datasources/auth_local_datasource.dart
Normal file
122
lib/features/auth/data/datasources/auth_local_datasource.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
/// Authentication Local Data Source
|
||||
///
|
||||
/// Handles secure local storage of authentication session data.
|
||||
/// Uses flutter_secure_storage for SID and CSRF token (encrypted).
|
||||
library;
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:worker/features/auth/data/models/auth_session_model.dart';
|
||||
|
||||
/// Authentication Local Data Source
|
||||
///
|
||||
/// Manages session data (SID, CSRF token) using secure storage.
|
||||
/// Session tokens are stored encrypted on device.
|
||||
class AuthLocalDataSource {
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
|
||||
/// Secure storage keys
|
||||
static const String _sidKey = 'auth_session_sid';
|
||||
static const String _csrfTokenKey = 'auth_session_csrf_token';
|
||||
static const String _fullNameKey = 'auth_session_full_name';
|
||||
static const String _createdAtKey = 'auth_session_created_at';
|
||||
static const String _appsKey = 'auth_session_apps';
|
||||
|
||||
AuthLocalDataSource(this._secureStorage);
|
||||
|
||||
/// Save session data securely
|
||||
///
|
||||
/// Stores SID, CSRF token, and user info in encrypted storage.
|
||||
Future<void> saveSession(SessionData session) async {
|
||||
await _secureStorage.write(key: _sidKey, value: session.sid);
|
||||
await _secureStorage.write(key: _csrfTokenKey, value: session.csrfToken);
|
||||
await _secureStorage.write(key: _fullNameKey, value: session.fullName);
|
||||
await _secureStorage.write(
|
||||
key: _createdAtKey,
|
||||
value: session.createdAt.toIso8601String(),
|
||||
);
|
||||
|
||||
// Store apps as JSON string if available
|
||||
if (session.apps != null && session.apps!.isNotEmpty) {
|
||||
final appsJson = session.apps!.map((app) => app.toJson()).toList();
|
||||
// Convert to JSON string for storage
|
||||
await _secureStorage.write(key: _appsKey, value: appsJson.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stored session data
|
||||
///
|
||||
/// Returns null if no session is stored.
|
||||
Future<SessionData?> getSession() async {
|
||||
final sid = await _secureStorage.read(key: _sidKey);
|
||||
final csrfToken = await _secureStorage.read(key: _csrfTokenKey);
|
||||
final fullName = await _secureStorage.read(key: _fullNameKey);
|
||||
final createdAtStr = await _secureStorage.read(key: _createdAtKey);
|
||||
|
||||
if (sid == null || csrfToken == null || fullName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final createdAt = createdAtStr != null
|
||||
? DateTime.tryParse(createdAtStr) ?? DateTime.now()
|
||||
: DateTime.now();
|
||||
|
||||
// TODO: Parse apps from JSON string if needed
|
||||
// For now, apps are optional
|
||||
|
||||
return SessionData(
|
||||
sid: sid,
|
||||
csrfToken: csrfToken,
|
||||
fullName: fullName,
|
||||
createdAt: createdAt,
|
||||
apps: null, // TODO: Parse from stored JSON if needed
|
||||
);
|
||||
}
|
||||
|
||||
/// Get SID (Session ID)
|
||||
///
|
||||
/// Returns null if not logged in.
|
||||
Future<String?> getSid() async {
|
||||
return await _secureStorage.read(key: _sidKey);
|
||||
}
|
||||
|
||||
/// Get CSRF Token
|
||||
///
|
||||
/// Returns null if not logged in.
|
||||
Future<String?> getCsrfToken() async {
|
||||
return await _secureStorage.read(key: _csrfTokenKey);
|
||||
}
|
||||
|
||||
/// Get Full Name
|
||||
///
|
||||
/// Returns null if not logged in.
|
||||
Future<String?> getFullName() async {
|
||||
return await _secureStorage.read(key: _fullNameKey);
|
||||
}
|
||||
|
||||
/// Check if user has valid session
|
||||
///
|
||||
/// Returns true if SID and CSRF token are present.
|
||||
Future<bool> hasValidSession() async {
|
||||
final sid = await getSid();
|
||||
final csrfToken = await getCsrfToken();
|
||||
return sid != null && csrfToken != null;
|
||||
}
|
||||
|
||||
/// Clear session data
|
||||
///
|
||||
/// Called during logout to remove all session information.
|
||||
Future<void> clearSession() async {
|
||||
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);
|
||||
}
|
||||
|
||||
/// Clear all authentication data
|
||||
///
|
||||
/// Complete cleanup of all stored auth data.
|
||||
Future<void> clearAll() async {
|
||||
await _secureStorage.deleteAll();
|
||||
}
|
||||
}
|
||||
86
lib/features/auth/data/models/auth_session_model.dart
Normal file
86
lib/features/auth/data/models/auth_session_model.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
/// Authentication Session Model
|
||||
///
|
||||
/// Models for API authentication response structure.
|
||||
/// Matches the ERPNext login API response format.
|
||||
library;
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'auth_session_model.freezed.dart';
|
||||
part 'auth_session_model.g.dart';
|
||||
|
||||
/// App Information
|
||||
///
|
||||
/// Represents an available app in the system.
|
||||
@freezed
|
||||
sealed class AppInfo with _$AppInfo {
|
||||
const factory AppInfo({
|
||||
@JsonKey(name: 'app_title') required String appTitle,
|
||||
@JsonKey(name: 'app_endpoint') required String appEndpoint,
|
||||
@JsonKey(name: 'app_logo') required String appLogo,
|
||||
}) = _AppInfo;
|
||||
|
||||
factory AppInfo.fromJson(Map<String, dynamic> json) =>
|
||||
_$AppInfoFromJson(json);
|
||||
}
|
||||
|
||||
/// Login Response Message
|
||||
///
|
||||
/// Contains the core authentication data from login response.
|
||||
@freezed
|
||||
sealed class LoginMessage with _$LoginMessage {
|
||||
const factory LoginMessage({
|
||||
required bool success,
|
||||
required String message,
|
||||
required String sid,
|
||||
@JsonKey(name: 'csrf_token') required String csrfToken,
|
||||
@Default([]) List<AppInfo> apps,
|
||||
}) = _LoginMessage;
|
||||
|
||||
factory LoginMessage.fromJson(Map<String, dynamic> json) =>
|
||||
_$LoginMessageFromJson(json);
|
||||
}
|
||||
|
||||
/// Authentication Session Response
|
||||
///
|
||||
/// Complete authentication response from ERPNext login API.
|
||||
@freezed
|
||||
sealed class AuthSessionResponse with _$AuthSessionResponse {
|
||||
const factory AuthSessionResponse({
|
||||
@JsonKey(name: 'session_expired') required int sessionExpired,
|
||||
required LoginMessage message,
|
||||
@JsonKey(name: 'home_page') required String homePage,
|
||||
@JsonKey(name: 'full_name') required String fullName,
|
||||
}) = _AuthSessionResponse;
|
||||
|
||||
factory AuthSessionResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$AuthSessionResponseFromJson(json);
|
||||
}
|
||||
|
||||
/// Session Storage Model
|
||||
///
|
||||
/// Simplified model for storing session data in Hive.
|
||||
@freezed
|
||||
sealed class SessionData with _$SessionData {
|
||||
const factory SessionData({
|
||||
required String sid,
|
||||
required String csrfToken,
|
||||
required String fullName,
|
||||
required DateTime createdAt,
|
||||
List<AppInfo>? apps,
|
||||
}) = _SessionData;
|
||||
|
||||
factory SessionData.fromJson(Map<String, dynamic> json) =>
|
||||
_$SessionDataFromJson(json);
|
||||
|
||||
/// Create from API response
|
||||
factory SessionData.fromAuthResponse(AuthSessionResponse response) {
|
||||
return SessionData(
|
||||
sid: response.message.sid,
|
||||
csrfToken: response.message.csrfToken,
|
||||
fullName: response.fullName,
|
||||
createdAt: DateTime.now(),
|
||||
apps: response.message.apps,
|
||||
);
|
||||
}
|
||||
}
|
||||
1113
lib/features/auth/data/models/auth_session_model.freezed.dart
Normal file
1113
lib/features/auth/data/models/auth_session_model.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
131
lib/features/auth/data/models/auth_session_model.g.dart
Normal file
131
lib/features/auth/data/models/auth_session_model.g.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth_session_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_AppInfo _$AppInfoFromJson(Map<String, dynamic> json) => $checkedCreate(
|
||||
'_AppInfo',
|
||||
json,
|
||||
($checkedConvert) {
|
||||
final val = _AppInfo(
|
||||
appTitle: $checkedConvert('app_title', (v) => v as String),
|
||||
appEndpoint: $checkedConvert('app_endpoint', (v) => v as String),
|
||||
appLogo: $checkedConvert('app_logo', (v) => v as String),
|
||||
);
|
||||
return val;
|
||||
},
|
||||
fieldKeyMap: const {
|
||||
'appTitle': 'app_title',
|
||||
'appEndpoint': 'app_endpoint',
|
||||
'appLogo': 'app_logo',
|
||||
},
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppInfoToJson(_AppInfo instance) => <String, dynamic>{
|
||||
'app_title': instance.appTitle,
|
||||
'app_endpoint': instance.appEndpoint,
|
||||
'app_logo': instance.appLogo,
|
||||
};
|
||||
|
||||
_LoginMessage _$LoginMessageFromJson(Map<String, dynamic> json) =>
|
||||
$checkedCreate('_LoginMessage', json, ($checkedConvert) {
|
||||
final val = _LoginMessage(
|
||||
success: $checkedConvert('success', (v) => v as bool),
|
||||
message: $checkedConvert('message', (v) => v as String),
|
||||
sid: $checkedConvert('sid', (v) => v as String),
|
||||
csrfToken: $checkedConvert('csrf_token', (v) => v as String),
|
||||
apps: $checkedConvert(
|
||||
'apps',
|
||||
(v) =>
|
||||
(v as List<dynamic>?)
|
||||
?.map((e) => AppInfo.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
),
|
||||
);
|
||||
return val;
|
||||
}, fieldKeyMap: const {'csrfToken': 'csrf_token'});
|
||||
|
||||
Map<String, dynamic> _$LoginMessageToJson(_LoginMessage instance) =>
|
||||
<String, dynamic>{
|
||||
'success': instance.success,
|
||||
'message': instance.message,
|
||||
'sid': instance.sid,
|
||||
'csrf_token': instance.csrfToken,
|
||||
'apps': instance.apps.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
_AuthSessionResponse _$AuthSessionResponseFromJson(Map<String, dynamic> json) =>
|
||||
$checkedCreate(
|
||||
'_AuthSessionResponse',
|
||||
json,
|
||||
($checkedConvert) {
|
||||
final val = _AuthSessionResponse(
|
||||
sessionExpired: $checkedConvert(
|
||||
'session_expired',
|
||||
(v) => (v as num).toInt(),
|
||||
),
|
||||
message: $checkedConvert(
|
||||
'message',
|
||||
(v) => LoginMessage.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 {
|
||||
'sessionExpired': 'session_expired',
|
||||
'homePage': 'home_page',
|
||||
'fullName': 'full_name',
|
||||
},
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AuthSessionResponseToJson(
|
||||
_AuthSessionResponse instance,
|
||||
) => <String, dynamic>{
|
||||
'session_expired': instance.sessionExpired,
|
||||
'message': instance.message.toJson(),
|
||||
'home_page': instance.homePage,
|
||||
'full_name': instance.fullName,
|
||||
};
|
||||
|
||||
_SessionData _$SessionDataFromJson(Map<String, dynamic> json) => $checkedCreate(
|
||||
'_SessionData',
|
||||
json,
|
||||
($checkedConvert) {
|
||||
final val = _SessionData(
|
||||
sid: $checkedConvert('sid', (v) => v as String),
|
||||
csrfToken: $checkedConvert('csrf_token', (v) => v as String),
|
||||
fullName: $checkedConvert('full_name', (v) => v as String),
|
||||
createdAt: $checkedConvert(
|
||||
'created_at',
|
||||
(v) => DateTime.parse(v as String),
|
||||
),
|
||||
apps: $checkedConvert(
|
||||
'apps',
|
||||
(v) => (v as List<dynamic>?)
|
||||
?.map((e) => AppInfo.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
return val;
|
||||
},
|
||||
fieldKeyMap: const {
|
||||
'csrfToken': 'csrf_token',
|
||||
'fullName': 'full_name',
|
||||
'createdAt': 'created_at',
|
||||
},
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SessionDataToJson(_SessionData instance) =>
|
||||
<String, dynamic>{
|
||||
'sid': instance.sid,
|
||||
'csrf_token': instance.csrfToken,
|
||||
'full_name': instance.fullName,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'apps': ?instance.apps?.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
@@ -19,7 +19,7 @@ enum UserRole {
|
||||
accountant,
|
||||
|
||||
/// Designer
|
||||
designer;
|
||||
designer,
|
||||
}
|
||||
|
||||
/// User status enum
|
||||
@@ -34,7 +34,7 @@ enum UserStatus {
|
||||
suspended,
|
||||
|
||||
/// Rejected account
|
||||
rejected;
|
||||
rejected,
|
||||
}
|
||||
|
||||
/// Loyalty tier enum
|
||||
|
||||
492
lib/features/auth/presentation/pages/login_page.dart
Normal file
492
lib/features/auth/presentation/pages/login_page.dart
Normal file
@@ -0,0 +1,492 @@
|
||||
/// Login Page
|
||||
///
|
||||
/// Main authentication page for the Worker app.
|
||||
/// Allows users to login with phone number and password.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/router/app_router.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/core/utils/validators.dart';
|
||||
import 'package:worker/features/auth/presentation/providers/auth_provider.dart';
|
||||
import 'package:worker/features/auth/presentation/providers/password_visibility_provider.dart';
|
||||
import 'package:worker/features/auth/presentation/widgets/phone_input_field.dart';
|
||||
|
||||
/// Login Page
|
||||
///
|
||||
/// Provides phone and password authentication.
|
||||
/// On successful login, navigates to home page.
|
||||
/// Links to registration page for new users.
|
||||
///
|
||||
/// Features:
|
||||
/// - Phone number input with Vietnamese format validation
|
||||
/// - Password input with visibility toggle
|
||||
/// - Form validation
|
||||
/// - Loading states
|
||||
/// - Error handling with snackbar
|
||||
/// - Link to registration
|
||||
/// - Customer support link
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
// Form key for validation
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Controllers
|
||||
final _phoneController = TextEditingController(text: "0988111111");
|
||||
final _passwordController = TextEditingController(text: "123456");
|
||||
|
||||
// Focus nodes
|
||||
final _phoneFocusNode = FocusNode();
|
||||
final _passwordFocusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_passwordController.dispose();
|
||||
_phoneFocusNode.dispose();
|
||||
_passwordFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Handle login button press
|
||||
Future<void> _handleLogin() async {
|
||||
// Validate form
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Unfocus keyboard
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
try {
|
||||
// Call login method
|
||||
await ref
|
||||
.read(authProvider.notifier)
|
||||
.login(
|
||||
phoneNumber: _phoneController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
);
|
||||
|
||||
// Check if login was successful
|
||||
final authState = ref.read(authProvider);
|
||||
authState.when(
|
||||
data: (user) {
|
||||
if (user != null && mounted) {
|
||||
// Navigate to home on success
|
||||
context.goHome();
|
||||
}
|
||||
},
|
||||
loading: () {},
|
||||
error: (error, stack) {
|
||||
// Show error snackbar
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error.toString()),
|
||||
backgroundColor: AppColors.danger,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// Show error snackbar
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Đăng nhập thất bại: ${e.toString()}'),
|
||||
backgroundColor: AppColors.danger,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to register page
|
||||
void _navigateToRegister() {
|
||||
// TODO: Navigate to register page when route is set up
|
||||
// context.go('/register');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Chức năng đăng ký đang được phát triển'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show support dialog
|
||||
void _showSupport() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Hỗ trợ khách hàng'),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Hotline: 1900 xxxx'),
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
Text('Email: support@eurotile.vn'),
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
Text('Giờ làm việc: 8:00 - 17:00 (T2-T6)'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Đóng'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Watch auth state for loading indicator
|
||||
final authState = ref.watch(authProvider);
|
||||
final isPasswordVisible = ref.watch(passwordVisibilityProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
|
||||
// Logo Section
|
||||
_buildLogo(),
|
||||
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
|
||||
// Welcome Message
|
||||
_buildWelcomeMessage(),
|
||||
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
|
||||
// Login Form Card
|
||||
_buildLoginForm(authState, isPasswordVisible),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Register Link
|
||||
_buildRegisterLink(),
|
||||
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
|
||||
// Support Link
|
||||
_buildSupportLink(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build logo section
|
||||
Widget _buildLogo() {
|
||||
return Center(
|
||||
child: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build welcome message
|
||||
Widget _buildWelcomeMessage() {
|
||||
return const Column(
|
||||
children: [
|
||||
Text(
|
||||
'Xin chào!',
|
||||
style: TextStyle(
|
||||
fontSize: 32.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
'Đăng nhập để tiếp tục',
|
||||
style: TextStyle(fontSize: 16.0, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Build login form card
|
||||
Widget _buildLoginForm(
|
||||
AsyncValue<dynamic> authState,
|
||||
bool isPasswordVisible,
|
||||
) {
|
||||
final isLoading = authState.isLoading;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10.0,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Phone Input
|
||||
PhoneInputField(
|
||||
controller: _phoneController,
|
||||
focusNode: _phoneFocusNode,
|
||||
validator: Validators.phone,
|
||||
enabled: !isLoading,
|
||||
onFieldSubmitted: (_) {
|
||||
// Move focus to password field
|
||||
FocusScope.of(context).requestFocus(_passwordFocusNode);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Password Input
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocusNode,
|
||||
enabled: !isLoading,
|
||||
obscureText: !isPasswordVisible,
|
||||
textInputAction: TextInputAction.done,
|
||||
style: const TextStyle(
|
||||
fontSize: InputFieldSpecs.fontSize,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Mật khẩu',
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: InputFieldSpecs.labelFontSize,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
hintText: 'Nhập mật khẩu',
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: InputFieldSpecs.hintFontSize,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.lock,
|
||||
color: AppColors.primaryBlue,
|
||||
size: AppIconSize.md,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
isPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
color: AppColors.grey500,
|
||||
size: AppIconSize.md,
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(passwordVisibilityProvider.notifier).toggle();
|
||||
},
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.white,
|
||||
contentPadding: InputFieldSpecs.contentPadding,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
InputFieldSpecs.borderRadius,
|
||||
),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.grey100,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
InputFieldSpecs.borderRadius,
|
||||
),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.grey100,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
InputFieldSpecs.borderRadius,
|
||||
),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.primaryBlue,
|
||||
width: 2.0,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
InputFieldSpecs.borderRadius,
|
||||
),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.danger,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
InputFieldSpecs.borderRadius,
|
||||
),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.danger,
|
||||
width: 2.0,
|
||||
),
|
||||
),
|
||||
errorStyle: const TextStyle(
|
||||
fontSize: 12.0,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
),
|
||||
validator: (value) =>
|
||||
Validators.passwordSimple(value, minLength: 6),
|
||||
onFieldSubmitted: (_) {
|
||||
if (!isLoading) {
|
||||
_handleLogin();
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Login Button
|
||||
SizedBox(
|
||||
height: ButtonSpecs.height,
|
||||
child: ElevatedButton(
|
||||
onPressed: isLoading ? null : _handleLogin,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
foregroundColor: AppColors.white,
|
||||
disabledBackgroundColor: AppColors.grey100,
|
||||
disabledForegroundColor: AppColors.grey500,
|
||||
elevation: ButtonSpecs.elevation,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(ButtonSpecs.borderRadius),
|
||||
),
|
||||
),
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
height: 20.0,
|
||||
width: 20.0,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Đăng nhập',
|
||||
style: TextStyle(
|
||||
fontSize: ButtonSpecs.fontSize,
|
||||
fontWeight: ButtonSpecs.fontWeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build register link
|
||||
Widget _buildRegisterLink() {
|
||||
return Center(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: 'Chưa có tài khoản? ',
|
||||
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: GestureDetector(
|
||||
onTap: _navigateToRegister,
|
||||
child: const Text(
|
||||
'Đăng ký ngay',
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: AppColors.primaryBlue,
|
||||
fontWeight: FontWeight.w500,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build support link
|
||||
Widget _buildSupportLink() {
|
||||
return Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: _showSupport,
|
||||
icon: const Icon(
|
||||
Icons.headset_mic,
|
||||
size: AppIconSize.sm,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
label: const Text(
|
||||
'Hỗ trợ khách hàng',
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: AppColors.primaryBlue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
790
lib/features/auth/presentation/pages/register_page.dart
Normal file
790
lib/features/auth/presentation/pages/register_page.dart
Normal file
@@ -0,0 +1,790 @@
|
||||
/// Registration Page
|
||||
///
|
||||
/// User registration form with role-based verification requirements.
|
||||
/// Matches design from html/register.html
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/core/utils/validators.dart';
|
||||
import 'package:worker/features/auth/presentation/widgets/phone_input_field.dart';
|
||||
import 'package:worker/features/auth/presentation/widgets/file_upload_card.dart';
|
||||
import 'package:worker/features/auth/presentation/widgets/role_dropdown.dart';
|
||||
|
||||
/// Registration Page
|
||||
///
|
||||
/// Features:
|
||||
/// - Full name, phone, email, password fields
|
||||
/// - Role selection (dealer/worker/broker/other)
|
||||
/// - Conditional verification section for workers/dealers
|
||||
/// - File upload for ID card and certificate
|
||||
/// - Company name and city selection
|
||||
/// - Terms and conditions checkbox
|
||||
///
|
||||
/// Navigation:
|
||||
/// - From: Login page
|
||||
/// - To: OTP verification (broker/other) or pending approval (worker/dealer)
|
||||
class RegisterPage extends ConsumerStatefulWidget {
|
||||
const RegisterPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RegisterPage> createState() => _RegisterPageState();
|
||||
}
|
||||
|
||||
class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||
// Form key
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Text controllers
|
||||
final _fullNameController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _idNumberController = TextEditingController();
|
||||
final _taxCodeController = TextEditingController();
|
||||
final _companyController = TextEditingController();
|
||||
|
||||
// Focus nodes
|
||||
final _fullNameFocus = FocusNode();
|
||||
final _phoneFocus = FocusNode();
|
||||
final _emailFocus = FocusNode();
|
||||
final _passwordFocus = FocusNode();
|
||||
final _idNumberFocus = FocusNode();
|
||||
final _taxCodeFocus = FocusNode();
|
||||
final _companyFocus = FocusNode();
|
||||
|
||||
// State
|
||||
String? _selectedRole;
|
||||
String? _selectedCity;
|
||||
File? _idCardFile;
|
||||
File? _certificateFile;
|
||||
bool _termsAccepted = false;
|
||||
bool _passwordVisible = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
final _imagePicker = ImagePicker();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fullNameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_idNumberController.dispose();
|
||||
_taxCodeController.dispose();
|
||||
_companyController.dispose();
|
||||
_fullNameFocus.dispose();
|
||||
_phoneFocus.dispose();
|
||||
_emailFocus.dispose();
|
||||
_passwordFocus.dispose();
|
||||
_idNumberFocus.dispose();
|
||||
_taxCodeFocus.dispose();
|
||||
_companyFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Check if verification section should be shown
|
||||
bool get _shouldShowVerification {
|
||||
return _selectedRole == 'worker' || _selectedRole == 'dealer';
|
||||
}
|
||||
|
||||
/// Pick image from gallery or camera
|
||||
Future<void> _pickImage(bool isIdCard) async {
|
||||
try {
|
||||
// Show bottom sheet to select source
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt),
|
||||
title: const Text('Chụp ảnh'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.camera),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library),
|
||||
title: const Text('Chọn từ thư viện'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.gallery),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (source == null) return;
|
||||
|
||||
final pickedFile = await _imagePicker.pickImage(
|
||||
source: source,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1080,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (pickedFile == null) return;
|
||||
|
||||
final file = File(pickedFile.path);
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
final fileSize = await file.length();
|
||||
if (fileSize > 5 * 1024 * 1024) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('File không được vượt quá 5MB'),
|
||||
backgroundColor: AppColors.danger,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
if (isIdCard) {
|
||||
_idCardFile = file;
|
||||
} else {
|
||||
_certificateFile = file;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Lỗi chọn ảnh: $e'),
|
||||
backgroundColor: AppColors.danger,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove selected image
|
||||
void _removeImage(bool isIdCard) {
|
||||
setState(() {
|
||||
if (isIdCard) {
|
||||
_idCardFile = null;
|
||||
} else {
|
||||
_certificateFile = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Validate form and submit
|
||||
Future<void> _handleRegister() async {
|
||||
// Validate form
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check terms acceptance
|
||||
if (!_termsAccepted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Vui lòng đồng ý với Điều khoản sử dụng và Chính sách bảo mật',
|
||||
),
|
||||
backgroundColor: AppColors.warning,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate verification requirements for workers/dealers
|
||||
if (_shouldShowVerification) {
|
||||
if (_idNumberController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Vui lòng nhập số CCCD/CMND'),
|
||||
backgroundColor: AppColors.warning,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_idCardFile == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Vui lòng tải lên ảnh CCCD/CMND'),
|
||||
backgroundColor: AppColors.warning,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_certificateFile == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Vui lòng tải lên ảnh chứng chỉ hành nghề hoặc GPKD'),
|
||||
backgroundColor: AppColors.warning,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// TODO: Implement actual registration API call
|
||||
// For now, simulate API delay
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
if (mounted) {
|
||||
// Navigate based on role
|
||||
if (_shouldShowVerification) {
|
||||
// For workers/dealers with verification, show pending page
|
||||
// TODO: Navigate to pending approval page
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Đăng ký thành công! Tài khoản đang chờ xét duyệt.',
|
||||
),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
context.pop();
|
||||
} else {
|
||||
// For other roles, navigate to OTP verification
|
||||
// TODO: Navigate to OTP verification page
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Đăng ký thành công! Vui lòng xác thực OTP.'),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
// context.push('/otp-verification');
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Đăng ký thất bại: $e'),
|
||||
backgroundColor: AppColors.danger,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.white,
|
||||
elevation: AppBarSpecs.elevation,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.black),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'Đăng ký tài khoản',
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: false,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Welcome section
|
||||
const Text(
|
||||
'Tạo tài khoản mới',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
const Text(
|
||||
'Điền thông tin để bắt đầu',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Form card
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Full Name
|
||||
_buildLabel('Họ và tên *'),
|
||||
TextFormField(
|
||||
controller: _fullNameController,
|
||||
focusNode: _fullNameFocus,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: _buildInputDecoration(
|
||||
hintText: 'Nhập họ và tên',
|
||||
prefixIcon: Icons.person,
|
||||
),
|
||||
validator: (value) => Validators.minLength(
|
||||
value,
|
||||
3,
|
||||
fieldName: 'Họ và tên',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Phone Number
|
||||
_buildLabel('Số điện thoại *'),
|
||||
PhoneInputField(
|
||||
controller: _phoneController,
|
||||
focusNode: _phoneFocus,
|
||||
validator: Validators.phone,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Email
|
||||
_buildLabel('Email *'),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
focusNode: _emailFocus,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: _buildInputDecoration(
|
||||
hintText: 'Nhập email',
|
||||
prefixIcon: Icons.email,
|
||||
),
|
||||
validator: Validators.email,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Password
|
||||
_buildLabel('Mật khẩu *'),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocus,
|
||||
obscureText: !_passwordVisible,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: _buildInputDecoration(
|
||||
hintText: 'Tạo mật khẩu mới',
|
||||
prefixIcon: Icons.lock,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_passwordVisible
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_passwordVisible = !_passwordVisible;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) =>
|
||||
Validators.passwordSimple(value, minLength: 6),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
const Text(
|
||||
'Mật khẩu tối thiểu 6 ký tự',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Role Selection
|
||||
_buildLabel('Vai trò *'),
|
||||
RoleDropdown(
|
||||
value: _selectedRole,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedRole = value;
|
||||
// Clear verification fields when role changes
|
||||
if (!_shouldShowVerification) {
|
||||
_idNumberController.clear();
|
||||
_taxCodeController.clear();
|
||||
_idCardFile = null;
|
||||
_certificateFile = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Vui lòng chọn vai trò';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Verification Section (conditional)
|
||||
if (_shouldShowVerification) ...[
|
||||
_buildVerificationSection(),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
],
|
||||
|
||||
// Company Name (optional)
|
||||
_buildLabel('Tên công ty/Cửa hàng'),
|
||||
TextFormField(
|
||||
controller: _companyController,
|
||||
focusNode: _companyFocus,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: _buildInputDecoration(
|
||||
hintText: 'Nhập tên công ty (không bắt buộc)',
|
||||
prefixIcon: Icons.business,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// City/Province
|
||||
_buildLabel('Tỉnh/Thành phố *'),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedCity,
|
||||
decoration: _buildInputDecoration(
|
||||
hintText: 'Chọn tỉnh/thành phố',
|
||||
prefixIcon: Icons.location_city,
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'hanoi',
|
||||
child: Text('Hà Nội'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'hcm',
|
||||
child: Text('TP. Hồ Chí Minh'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'danang',
|
||||
child: Text('Đà Nẵng'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'haiphong',
|
||||
child: Text('Hải Phòng'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'cantho',
|
||||
child: Text('Cần Thơ'),
|
||||
),
|
||||
DropdownMenuItem(value: 'other', child: Text('Khác')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedCity = value;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Vui lòng chọn tỉnh/thành phố';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Terms and Conditions
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _termsAccepted,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_termsAccepted = value ?? false;
|
||||
});
|
||||
},
|
||||
activeColor: AppColors.primaryBlue,
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_termsAccepted = !_termsAccepted;
|
||||
});
|
||||
},
|
||||
child: const Text.rich(
|
||||
TextSpan(
|
||||
text: 'Tôi đồng ý với ',
|
||||
style: TextStyle(fontSize: 13),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'Điều khoản sử dụng',
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryBlue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
TextSpan(text: ' và '),
|
||||
TextSpan(
|
||||
text: 'Chính sách bảo mật',
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryBlue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Register Button
|
||||
SizedBox(
|
||||
height: ButtonSpecs.height,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _handleRegister,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
foregroundColor: AppColors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
ButtonSpecs.borderRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Đăng ký',
|
||||
style: TextStyle(
|
||||
fontSize: ButtonSpecs.fontSize,
|
||||
fontWeight: ButtonSpecs.fontWeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Login Link
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Đã có tài khoản? ',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => context.pop(),
|
||||
child: const Text(
|
||||
'Đăng nhập',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.primaryBlue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build label widget
|
||||
Widget _buildLabel(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build input decoration
|
||||
InputDecoration _buildInputDecoration({
|
||||
required String hintText,
|
||||
required IconData prefixIcon,
|
||||
Widget? suffixIcon,
|
||||
}) {
|
||||
return InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: InputFieldSpecs.hintFontSize,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
prefixIcon,
|
||||
color: AppColors.primaryBlue,
|
||||
size: AppIconSize.md,
|
||||
),
|
||||
suffixIcon: suffixIcon,
|
||||
filled: true,
|
||||
fillColor: AppColors.white,
|
||||
contentPadding: InputFieldSpecs.contentPadding,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2.0),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.danger, width: 1.0),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.danger, width: 2.0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build verification section
|
||||
Widget _buildVerificationSection() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0), width: 2),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.shield, color: AppColors.primaryBlue, size: 20),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
const Text(
|
||||
'Thông tin xác thực',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
const Text(
|
||||
'Thông tin này sẽ được dùng để xác minh tư cách chuyên môn của bạn',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// ID Number
|
||||
_buildLabel('Số CCCD/CMND'),
|
||||
TextFormField(
|
||||
controller: _idNumberController,
|
||||
focusNode: _idNumberFocus,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: _buildInputDecoration(
|
||||
hintText: 'Nhập số CCCD/CMND',
|
||||
prefixIcon: Icons.badge,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Tax Code
|
||||
_buildLabel('Mã số thuế cá nhân/Công ty'),
|
||||
TextFormField(
|
||||
controller: _taxCodeController,
|
||||
focusNode: _taxCodeFocus,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: _buildInputDecoration(
|
||||
hintText: 'Nhập mã số thuế (không bắt buộc)',
|
||||
prefixIcon: Icons.receipt_long,
|
||||
),
|
||||
validator: Validators.taxIdOptional,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// ID Card Upload
|
||||
_buildLabel('Ảnh mặt trước CCCD/CMND'),
|
||||
FileUploadCard(
|
||||
file: _idCardFile,
|
||||
onTap: () => _pickImage(true),
|
||||
onRemove: () => _removeImage(true),
|
||||
icon: Icons.camera_alt,
|
||||
title: 'Chụp ảnh hoặc chọn file',
|
||||
subtitle: 'JPG, PNG tối đa 5MB',
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Certificate Upload
|
||||
_buildLabel('Ảnh chứng chỉ hành nghề hoặc GPKD'),
|
||||
FileUploadCard(
|
||||
file: _certificateFile,
|
||||
onTap: () => _pickImage(false),
|
||||
onRemove: () => _removeImage(false),
|
||||
icon: Icons.file_present,
|
||||
title: 'Chụp ảnh hoặc chọn file',
|
||||
subtitle: 'JPG, PNG tối đa 5MB',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
279
lib/features/auth/presentation/providers/auth_provider.dart
Normal file
279
lib/features/auth/presentation/providers/auth_provider.dart
Normal file
@@ -0,0 +1,279 @@
|
||||
/// Authentication State Provider
|
||||
///
|
||||
/// Manages authentication state for the Worker application.
|
||||
/// Handles login, logout, and user session management.
|
||||
///
|
||||
/// Uses Riverpod 3.0 with code generation for type-safe state management.
|
||||
library;
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart';
|
||||
import 'package:worker/features/auth/data/models/auth_session_model.dart';
|
||||
import 'package:worker/features/auth/domain/entities/user.dart';
|
||||
|
||||
part 'auth_provider.g.dart';
|
||||
|
||||
/// Provide FlutterSecureStorage instance
|
||||
@riverpod
|
||||
FlutterSecureStorage secureStorage(Ref ref) {
|
||||
return const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
}
|
||||
|
||||
/// Provide AuthLocalDataSource instance
|
||||
@riverpod
|
||||
AuthLocalDataSource authLocalDataSource(Ref ref) {
|
||||
final secureStorage = ref.watch(secureStorageProvider);
|
||||
return AuthLocalDataSource(secureStorage);
|
||||
}
|
||||
|
||||
/// Authentication state result
|
||||
///
|
||||
/// Represents the result of authentication operations.
|
||||
/// Contains either the authenticated user or null if logged out.
|
||||
typedef AuthState = AsyncValue<User?>;
|
||||
|
||||
/// Authentication Provider
|
||||
///
|
||||
/// Main provider for authentication state management.
|
||||
/// Provides login and logout functionality with async state handling.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final authState = ref.watch(authProvider);
|
||||
/// authState.when(
|
||||
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
@riverpod
|
||||
class Auth extends _$Auth {
|
||||
/// Get auth local data source
|
||||
AuthLocalDataSource get _localDataSource =>
|
||||
ref.read(authLocalDataSourceProvider);
|
||||
|
||||
/// 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
|
||||
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
|
||||
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: 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Logout current user
|
||||
///
|
||||
/// Clears authentication state and removes saved session from Hive.
|
||||
Future<void> logout() async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
state = await AsyncValue.guard(() async {
|
||||
// Clear saved session from Hive
|
||||
await _localDataSource.clearSession();
|
||||
|
||||
// TODO: Call logout API to invalidate token on server
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Return null to indicate logged out
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/// Get current authenticated user
|
||||
///
|
||||
/// Returns the current user if logged in, null otherwise.
|
||||
User? get currentUser => state.value;
|
||||
|
||||
/// Check if user is authenticated
|
||||
///
|
||||
/// Returns true if there is a logged-in user.
|
||||
bool get isAuthenticated => currentUser != null;
|
||||
|
||||
/// Check if authentication is in progress
|
||||
///
|
||||
/// Returns true during login/logout operations.
|
||||
bool get isLoading => state.isLoading;
|
||||
|
||||
/// Get authentication error if any
|
||||
///
|
||||
/// Returns error message or null if no error.
|
||||
Object? get error => state.error;
|
||||
}
|
||||
|
||||
/// Convenience provider for checking if user is authenticated
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isLoggedIn = ref.watch(isAuthenticatedProvider);
|
||||
/// if (isLoggedIn) {
|
||||
/// // Show home screen
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
bool isAuthenticated(Ref ref) {
|
||||
final authState = ref.watch(authProvider);
|
||||
return authState.value != null;
|
||||
}
|
||||
|
||||
/// Convenience provider for getting current user
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final user = ref.watch(currentUserProvider);
|
||||
/// if (user != null) {
|
||||
/// Text('Welcome ${user.fullName}');
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
User? currentUser(Ref ref) {
|
||||
final authState = ref.watch(authProvider);
|
||||
return authState.value;
|
||||
}
|
||||
|
||||
/// Convenience provider for user's loyalty tier
|
||||
///
|
||||
/// Returns the current user's loyalty tier or null if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final tier = ref.watch(userLoyaltyTierProvider);
|
||||
/// if (tier != null) {
|
||||
/// Text('Tier: ${tier.displayName}');
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
LoyaltyTier? userLoyaltyTier(Ref ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
return user?.loyaltyTier;
|
||||
}
|
||||
|
||||
/// Convenience provider for user's total points
|
||||
///
|
||||
/// Returns the current user's total loyalty points or 0 if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final points = ref.watch(userTotalPointsProvider);
|
||||
/// Text('Points: $points');
|
||||
/// ```
|
||||
@riverpod
|
||||
int userTotalPoints(Ref ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
return user?.totalPoints ?? 0;
|
||||
}
|
||||
500
lib/features/auth/presentation/providers/auth_provider.g.dart
Normal file
500
lib/features/auth/presentation/providers/auth_provider.g.dart
Normal file
@@ -0,0 +1,500 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Provide FlutterSecureStorage instance
|
||||
|
||||
@ProviderFor(secureStorage)
|
||||
const secureStorageProvider = SecureStorageProvider._();
|
||||
|
||||
/// Provide FlutterSecureStorage instance
|
||||
|
||||
final class SecureStorageProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
FlutterSecureStorage,
|
||||
FlutterSecureStorage,
|
||||
FlutterSecureStorage
|
||||
>
|
||||
with $Provider<FlutterSecureStorage> {
|
||||
/// Provide FlutterSecureStorage instance
|
||||
const SecureStorageProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'secureStorageProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$secureStorageHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<FlutterSecureStorage> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FlutterSecureStorage create(Ref ref) {
|
||||
return secureStorage(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(FlutterSecureStorage value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<FlutterSecureStorage>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$secureStorageHash() => r'c3d90388f6d1bb7c95a29ceeda2e56c57deb1ecb';
|
||||
|
||||
/// Provide AuthLocalDataSource instance
|
||||
|
||||
@ProviderFor(authLocalDataSource)
|
||||
const authLocalDataSourceProvider = AuthLocalDataSourceProvider._();
|
||||
|
||||
/// Provide AuthLocalDataSource instance
|
||||
|
||||
final class AuthLocalDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AuthLocalDataSource,
|
||||
AuthLocalDataSource,
|
||||
AuthLocalDataSource
|
||||
>
|
||||
with $Provider<AuthLocalDataSource> {
|
||||
/// Provide AuthLocalDataSource instance
|
||||
const AuthLocalDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'authLocalDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$authLocalDataSourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<AuthLocalDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
AuthLocalDataSource create(Ref ref) {
|
||||
return authLocalDataSource(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AuthLocalDataSource value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AuthLocalDataSource>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$authLocalDataSourceHash() =>
|
||||
r'f104de00a8ab431f6736387fb499c2b6e0ab4924';
|
||||
|
||||
/// Authentication Provider
|
||||
///
|
||||
/// Main provider for authentication state management.
|
||||
/// Provides login and logout functionality with async state handling.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final authState = ref.watch(authProvider);
|
||||
/// authState.when(
|
||||
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
@ProviderFor(Auth)
|
||||
const authProvider = AuthProvider._();
|
||||
|
||||
/// Authentication Provider
|
||||
///
|
||||
/// Main provider for authentication state management.
|
||||
/// Provides login and logout functionality with async state handling.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final authState = ref.watch(authProvider);
|
||||
/// authState.when(
|
||||
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
final class AuthProvider extends $AsyncNotifierProvider<Auth, User?> {
|
||||
/// Authentication Provider
|
||||
///
|
||||
/// Main provider for authentication state management.
|
||||
/// Provides login and logout functionality with async state handling.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final authState = ref.watch(authProvider);
|
||||
/// authState.when(
|
||||
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
const AuthProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'authProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$authHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
Auth create() => Auth();
|
||||
}
|
||||
|
||||
String _$authHash() => r'6f410d1abe6c53a6cbfa52fde7ea7a2d22a7f78d';
|
||||
|
||||
/// Authentication Provider
|
||||
///
|
||||
/// Main provider for authentication state management.
|
||||
/// Provides login and logout functionality with async state handling.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final authState = ref.watch(authProvider);
|
||||
/// authState.when(
|
||||
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
abstract class _$Auth extends $AsyncNotifier<User?> {
|
||||
FutureOr<User?> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<AsyncValue<User?>, User?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<User?>, User?>,
|
||||
AsyncValue<User?>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience provider for checking if user is authenticated
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isLoggedIn = ref.watch(isAuthenticatedProvider);
|
||||
/// if (isLoggedIn) {
|
||||
/// // Show home screen
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(isAuthenticated)
|
||||
const isAuthenticatedProvider = IsAuthenticatedProvider._();
|
||||
|
||||
/// Convenience provider for checking if user is authenticated
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isLoggedIn = ref.watch(isAuthenticatedProvider);
|
||||
/// if (isLoggedIn) {
|
||||
/// // Show home screen
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
final class IsAuthenticatedProvider
|
||||
extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
/// Convenience provider for checking if user is authenticated
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isLoggedIn = ref.watch(isAuthenticatedProvider);
|
||||
/// if (isLoggedIn) {
|
||||
/// // Show home screen
|
||||
/// }
|
||||
/// ```
|
||||
const IsAuthenticatedProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'isAuthenticatedProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$isAuthenticatedHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
return isAuthenticated(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$isAuthenticatedHash() => r'dc783f052ad2ddb7fa18c58e5dc6d212e6c32a96';
|
||||
|
||||
/// Convenience provider for getting current user
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final user = ref.watch(currentUserProvider);
|
||||
/// if (user != null) {
|
||||
/// Text('Welcome ${user.fullName}');
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(currentUser)
|
||||
const currentUserProvider = CurrentUserProvider._();
|
||||
|
||||
/// Convenience provider for getting current user
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final user = ref.watch(currentUserProvider);
|
||||
/// if (user != null) {
|
||||
/// Text('Welcome ${user.fullName}');
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
final class CurrentUserProvider extends $FunctionalProvider<User?, User?, User?>
|
||||
with $Provider<User?> {
|
||||
/// Convenience provider for getting current user
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final user = ref.watch(currentUserProvider);
|
||||
/// if (user != null) {
|
||||
/// Text('Welcome ${user.fullName}');
|
||||
/// }
|
||||
/// ```
|
||||
const CurrentUserProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'currentUserProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$currentUserHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<User?> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
User? create(Ref ref) {
|
||||
return currentUser(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(User? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<User?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$currentUserHash() => r'f3c1da551f4a4c2bf158782ea37a4749a718128a';
|
||||
|
||||
/// Convenience provider for user's loyalty tier
|
||||
///
|
||||
/// Returns the current user's loyalty tier or null if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final tier = ref.watch(userLoyaltyTierProvider);
|
||||
/// if (tier != null) {
|
||||
/// Text('Tier: ${tier.displayName}');
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(userLoyaltyTier)
|
||||
const userLoyaltyTierProvider = UserLoyaltyTierProvider._();
|
||||
|
||||
/// Convenience provider for user's loyalty tier
|
||||
///
|
||||
/// Returns the current user's loyalty tier or null if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final tier = ref.watch(userLoyaltyTierProvider);
|
||||
/// if (tier != null) {
|
||||
/// Text('Tier: ${tier.displayName}');
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
final class UserLoyaltyTierProvider
|
||||
extends $FunctionalProvider<LoyaltyTier?, LoyaltyTier?, LoyaltyTier?>
|
||||
with $Provider<LoyaltyTier?> {
|
||||
/// Convenience provider for user's loyalty tier
|
||||
///
|
||||
/// Returns the current user's loyalty tier or null if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final tier = ref.watch(userLoyaltyTierProvider);
|
||||
/// if (tier != null) {
|
||||
/// Text('Tier: ${tier.displayName}');
|
||||
/// }
|
||||
/// ```
|
||||
const UserLoyaltyTierProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'userLoyaltyTierProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$userLoyaltyTierHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<LoyaltyTier?> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
LoyaltyTier? create(Ref ref) {
|
||||
return userLoyaltyTier(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(LoyaltyTier? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<LoyaltyTier?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$userLoyaltyTierHash() => r'f1a157486b8bdd2cf64bc2201207f2ac71ea6a69';
|
||||
|
||||
/// Convenience provider for user's total points
|
||||
///
|
||||
/// Returns the current user's total loyalty points or 0 if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final points = ref.watch(userTotalPointsProvider);
|
||||
/// Text('Points: $points');
|
||||
/// ```
|
||||
|
||||
@ProviderFor(userTotalPoints)
|
||||
const userTotalPointsProvider = UserTotalPointsProvider._();
|
||||
|
||||
/// Convenience provider for user's total points
|
||||
///
|
||||
/// Returns the current user's total loyalty points or 0 if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final points = ref.watch(userTotalPointsProvider);
|
||||
/// Text('Points: $points');
|
||||
/// ```
|
||||
|
||||
final class UserTotalPointsProvider extends $FunctionalProvider<int, int, int>
|
||||
with $Provider<int> {
|
||||
/// Convenience provider for user's total points
|
||||
///
|
||||
/// Returns the current user's total loyalty points or 0 if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final points = ref.watch(userTotalPointsProvider);
|
||||
/// Text('Points: $points');
|
||||
/// ```
|
||||
const UserTotalPointsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'userTotalPointsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$userTotalPointsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<int> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
int create(Ref ref) {
|
||||
return userTotalPoints(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(int value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<int>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$userTotalPointsHash() => r'9ccebb48a8641c3c0624b1649303b436e82602bd';
|
||||
@@ -0,0 +1,112 @@
|
||||
/// Password Visibility Provider
|
||||
///
|
||||
/// Simple state provider for toggling password visibility in login/register forms.
|
||||
///
|
||||
/// Uses Riverpod 3.0 with code generation for type-safe state management.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'password_visibility_provider.g.dart';
|
||||
|
||||
/// Password Visibility State Provider
|
||||
///
|
||||
/// Manages the visibility state of password input fields.
|
||||
/// Default state is false (password hidden).
|
||||
///
|
||||
/// Usage in login/register pages:
|
||||
/// ```dart
|
||||
/// class LoginPage extends ConsumerWidget {
|
||||
/// @override
|
||||
/// Widget build(BuildContext context, WidgetRef ref) {
|
||||
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
|
||||
///
|
||||
/// return TextField(
|
||||
/// obscureText: !isPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(passwordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
class PasswordVisibility extends _$PasswordVisibility {
|
||||
/// Initialize with password hidden (false)
|
||||
@override
|
||||
bool build() => false;
|
||||
|
||||
/// Toggle password visibility
|
||||
///
|
||||
/// Switches between showing and hiding the password.
|
||||
void toggle() {
|
||||
state = !state;
|
||||
}
|
||||
|
||||
/// Show password
|
||||
///
|
||||
/// Sets visibility to true (password visible).
|
||||
void show() {
|
||||
state = true;
|
||||
}
|
||||
|
||||
/// Hide password
|
||||
///
|
||||
/// Sets visibility to false (password hidden).
|
||||
void hide() {
|
||||
state = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm Password Visibility State Provider
|
||||
///
|
||||
/// Separate provider for confirm password field in registration forms.
|
||||
/// This allows independent control of password and confirm password visibility.
|
||||
///
|
||||
/// Usage in registration page:
|
||||
/// ```dart
|
||||
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
|
||||
///
|
||||
/// TextField(
|
||||
/// obscureText: !isConfirmPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// labelText: 'Xác nhận mật khẩu',
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
@riverpod
|
||||
class ConfirmPasswordVisibility extends _$ConfirmPasswordVisibility {
|
||||
/// Initialize with password hidden (false)
|
||||
@override
|
||||
bool build() => false;
|
||||
|
||||
/// Toggle confirm password visibility
|
||||
void toggle() {
|
||||
state = !state;
|
||||
}
|
||||
|
||||
/// Show confirm password
|
||||
void show() {
|
||||
state = true;
|
||||
}
|
||||
|
||||
/// Hide confirm password
|
||||
void hide() {
|
||||
state = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'password_visibility_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Password Visibility State Provider
|
||||
///
|
||||
/// Manages the visibility state of password input fields.
|
||||
/// Default state is false (password hidden).
|
||||
///
|
||||
/// Usage in login/register pages:
|
||||
/// ```dart
|
||||
/// class LoginPage extends ConsumerWidget {
|
||||
/// @override
|
||||
/// Widget build(BuildContext context, WidgetRef ref) {
|
||||
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
|
||||
///
|
||||
/// return TextField(
|
||||
/// obscureText: !isPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(passwordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(PasswordVisibility)
|
||||
const passwordVisibilityProvider = PasswordVisibilityProvider._();
|
||||
|
||||
/// Password Visibility State Provider
|
||||
///
|
||||
/// Manages the visibility state of password input fields.
|
||||
/// Default state is false (password hidden).
|
||||
///
|
||||
/// Usage in login/register pages:
|
||||
/// ```dart
|
||||
/// class LoginPage extends ConsumerWidget {
|
||||
/// @override
|
||||
/// Widget build(BuildContext context, WidgetRef ref) {
|
||||
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
|
||||
///
|
||||
/// return TextField(
|
||||
/// obscureText: !isPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(passwordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
final class PasswordVisibilityProvider
|
||||
extends $NotifierProvider<PasswordVisibility, bool> {
|
||||
/// Password Visibility State Provider
|
||||
///
|
||||
/// Manages the visibility state of password input fields.
|
||||
/// Default state is false (password hidden).
|
||||
///
|
||||
/// Usage in login/register pages:
|
||||
/// ```dart
|
||||
/// class LoginPage extends ConsumerWidget {
|
||||
/// @override
|
||||
/// Widget build(BuildContext context, WidgetRef ref) {
|
||||
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
|
||||
///
|
||||
/// return TextField(
|
||||
/// obscureText: !isPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(passwordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
const PasswordVisibilityProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'passwordVisibilityProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$passwordVisibilityHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
PasswordVisibility create() => PasswordVisibility();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$passwordVisibilityHash() =>
|
||||
r'25b6fa914e42dd83c8443aecbeb1d608cccd00ab';
|
||||
|
||||
/// Password Visibility State Provider
|
||||
///
|
||||
/// Manages the visibility state of password input fields.
|
||||
/// Default state is false (password hidden).
|
||||
///
|
||||
/// Usage in login/register pages:
|
||||
/// ```dart
|
||||
/// class LoginPage extends ConsumerWidget {
|
||||
/// @override
|
||||
/// Widget build(BuildContext context, WidgetRef ref) {
|
||||
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
|
||||
///
|
||||
/// return TextField(
|
||||
/// obscureText: !isPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(passwordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
abstract class _$PasswordVisibility extends $Notifier<bool> {
|
||||
bool build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<bool, bool>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<bool, bool>,
|
||||
bool,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm Password Visibility State Provider
|
||||
///
|
||||
/// Separate provider for confirm password field in registration forms.
|
||||
/// This allows independent control of password and confirm password visibility.
|
||||
///
|
||||
/// Usage in registration page:
|
||||
/// ```dart
|
||||
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
|
||||
///
|
||||
/// TextField(
|
||||
/// obscureText: !isConfirmPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// labelText: 'Xác nhận mật khẩu',
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
@ProviderFor(ConfirmPasswordVisibility)
|
||||
const confirmPasswordVisibilityProvider = ConfirmPasswordVisibilityProvider._();
|
||||
|
||||
/// Confirm Password Visibility State Provider
|
||||
///
|
||||
/// Separate provider for confirm password field in registration forms.
|
||||
/// This allows independent control of password and confirm password visibility.
|
||||
///
|
||||
/// Usage in registration page:
|
||||
/// ```dart
|
||||
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
|
||||
///
|
||||
/// TextField(
|
||||
/// obscureText: !isConfirmPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// labelText: 'Xác nhận mật khẩu',
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
final class ConfirmPasswordVisibilityProvider
|
||||
extends $NotifierProvider<ConfirmPasswordVisibility, bool> {
|
||||
/// Confirm Password Visibility State Provider
|
||||
///
|
||||
/// Separate provider for confirm password field in registration forms.
|
||||
/// This allows independent control of password and confirm password visibility.
|
||||
///
|
||||
/// Usage in registration page:
|
||||
/// ```dart
|
||||
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
|
||||
///
|
||||
/// TextField(
|
||||
/// obscureText: !isConfirmPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// labelText: 'Xác nhận mật khẩu',
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
const ConfirmPasswordVisibilityProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'confirmPasswordVisibilityProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$confirmPasswordVisibilityHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
ConfirmPasswordVisibility create() => ConfirmPasswordVisibility();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$confirmPasswordVisibilityHash() =>
|
||||
r'8408bba9db1e8deba425f98015a4e2fa76d75eb8';
|
||||
|
||||
/// Confirm Password Visibility State Provider
|
||||
///
|
||||
/// Separate provider for confirm password field in registration forms.
|
||||
/// This allows independent control of password and confirm password visibility.
|
||||
///
|
||||
/// Usage in registration page:
|
||||
/// ```dart
|
||||
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
|
||||
///
|
||||
/// TextField(
|
||||
/// obscureText: !isConfirmPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// labelText: 'Xác nhận mật khẩu',
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
abstract class _$ConfirmPasswordVisibility extends $Notifier<bool> {
|
||||
bool build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<bool, bool>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<bool, bool>,
|
||||
bool,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
305
lib/features/auth/presentation/providers/register_provider.dart
Normal file
305
lib/features/auth/presentation/providers/register_provider.dart
Normal file
@@ -0,0 +1,305 @@
|
||||
/// Registration State Provider
|
||||
///
|
||||
/// Manages registration state for the Worker application.
|
||||
/// Handles user registration with role-based validation and verification.
|
||||
///
|
||||
/// Uses Riverpod 3.0 with code generation for type-safe state management.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/auth/domain/entities/user.dart';
|
||||
|
||||
part 'register_provider.g.dart';
|
||||
|
||||
/// Registration Form Data
|
||||
///
|
||||
/// Contains all data needed for user registration.
|
||||
/// Optional fields are used based on selected role.
|
||||
class RegistrationData {
|
||||
/// Required: Full name of the user
|
||||
final String fullName;
|
||||
|
||||
/// Required: Phone number (Vietnamese format)
|
||||
final String phoneNumber;
|
||||
|
||||
/// Required: Email address
|
||||
final String email;
|
||||
|
||||
/// Required: Password (minimum 6 characters)
|
||||
final String password;
|
||||
|
||||
/// Required: User role
|
||||
final UserRole role;
|
||||
|
||||
/// Optional: CCCD/ID card number (required for dealer/worker roles)
|
||||
final String? cccd;
|
||||
|
||||
/// Optional: Tax code (personal or company)
|
||||
final String? taxCode;
|
||||
|
||||
/// Optional: Company/store name
|
||||
final String? companyName;
|
||||
|
||||
/// Required: Province/city
|
||||
final String? city;
|
||||
|
||||
/// Optional: Attachment file paths (ID card, certificate, license)
|
||||
final List<String>? attachments;
|
||||
|
||||
const RegistrationData({
|
||||
required this.fullName,
|
||||
required this.phoneNumber,
|
||||
required this.email,
|
||||
required this.password,
|
||||
required this.role,
|
||||
this.cccd,
|
||||
this.taxCode,
|
||||
this.companyName,
|
||||
this.city,
|
||||
this.attachments,
|
||||
});
|
||||
|
||||
/// Copy with method for immutability
|
||||
RegistrationData copyWith({
|
||||
String? fullName,
|
||||
String? phoneNumber,
|
||||
String? email,
|
||||
String? password,
|
||||
UserRole? role,
|
||||
String? cccd,
|
||||
String? taxCode,
|
||||
String? companyName,
|
||||
String? city,
|
||||
List<String>? attachments,
|
||||
}) {
|
||||
return RegistrationData(
|
||||
fullName: fullName ?? this.fullName,
|
||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||
email: email ?? this.email,
|
||||
password: password ?? this.password,
|
||||
role: role ?? this.role,
|
||||
cccd: cccd ?? this.cccd,
|
||||
taxCode: taxCode ?? this.taxCode,
|
||||
companyName: companyName ?? this.companyName,
|
||||
city: city ?? this.city,
|
||||
attachments: attachments ?? this.attachments,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registration State Provider
|
||||
///
|
||||
/// Main provider for user registration state management.
|
||||
/// Handles registration process with role-based validation.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final registerState = ref.watch(registerProvider);
|
||||
/// registerState.when(
|
||||
/// data: (user) => SuccessScreen(user),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
@riverpod
|
||||
class Register extends _$Register {
|
||||
/// Initialize with no registration result
|
||||
@override
|
||||
Future<User?> build() async {
|
||||
// No initial registration
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Register a new user
|
||||
///
|
||||
/// Performs user registration with role-based validation.
|
||||
/// For dealer/worker roles, requires additional verification documents.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [data]: Registration form data containing all required fields
|
||||
///
|
||||
/// Returns: Newly created User object on success
|
||||
///
|
||||
/// Throws: Exception on validation failure or registration error
|
||||
///
|
||||
/// Error messages (Vietnamese):
|
||||
/// - "Vui lòng điền đầy đủ thông tin bắt buộc"
|
||||
/// - "Số điện thoại không hợp lệ"
|
||||
/// - "Email không hợp lệ"
|
||||
/// - "Mật khẩu phải có ít nhất 6 ký tự"
|
||||
/// - "Vui lòng nhập số CCCD/CMND" (for dealer/worker)
|
||||
/// - "Vui lòng tải lên ảnh CCCD/CMND" (for dealer/worker)
|
||||
/// - "Vui lòng tải lên ảnh chứng chỉ hành nghề hoặc GPKD" (for dealer/worker)
|
||||
/// - "Số điện thoại đã được đăng ký"
|
||||
/// - "Email đã được đăng ký"
|
||||
Future<void> register(RegistrationData data) async {
|
||||
// Set loading state
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
// Perform registration with error handling
|
||||
state = await AsyncValue.guard(() async {
|
||||
// Validate required fields
|
||||
if (data.fullName.isEmpty ||
|
||||
data.phoneNumber.isEmpty ||
|
||||
data.email.isEmpty ||
|
||||
data.password.isEmpty ||
|
||||
data.city == null ||
|
||||
data.city!.isEmpty) {
|
||||
throw Exception('Vui lòng điền đầy đủ thông tin bắt buộc');
|
||||
}
|
||||
|
||||
// Validate phone number (Vietnamese format: 10 digits starting with 0)
|
||||
final phoneRegex = RegExp(r'^0[0-9]{9}$');
|
||||
if (!phoneRegex.hasMatch(data.phoneNumber)) {
|
||||
throw Exception('Số điện thoại không hợp lệ');
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
||||
if (!emailRegex.hasMatch(data.email)) {
|
||||
throw Exception('Email không hợp lệ');
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (data.password.length < 6) {
|
||||
throw Exception('Mật khẩu phải có ít nhất 6 ký tự');
|
||||
}
|
||||
|
||||
// Role-based validation for dealer/worker (requires verification)
|
||||
if (data.role == UserRole.customer) {
|
||||
// For dealer/worker roles, CCCD and attachments are required
|
||||
if (data.cccd == null || data.cccd!.isEmpty) {
|
||||
throw Exception('Vui lòng nhập số CCCD/CMND');
|
||||
}
|
||||
|
||||
// Validate CCCD format (9 or 12 digits)
|
||||
final cccdRegex = RegExp(r'^[0-9]{9}$|^[0-9]{12}$');
|
||||
if (!cccdRegex.hasMatch(data.cccd!)) {
|
||||
throw Exception('Số CCCD/CMND không hợp lệ (phải có 9 hoặc 12 số)');
|
||||
}
|
||||
|
||||
// Validate attachments
|
||||
if (data.attachments == null || data.attachments!.isEmpty) {
|
||||
throw Exception('Vui lòng tải lên ảnh CCCD/CMND');
|
||||
}
|
||||
|
||||
if (data.attachments!.length < 2) {
|
||||
throw Exception('Vui lòng tải lên ảnh chứng chỉ hành nghề hoặc GPKD');
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate API call delay (2 seconds)
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
|
||||
// TODO: In production, call the registration API here
|
||||
// final response = await ref.read(authRepositoryProvider).register(data);
|
||||
|
||||
// Mock: Simulate registration success
|
||||
final now = DateTime.now();
|
||||
|
||||
// Determine initial status based on role
|
||||
// Dealer/Worker require admin approval (pending status)
|
||||
// Other roles are immediately active
|
||||
final initialStatus = data.role == UserRole.customer
|
||||
? UserStatus.pending
|
||||
: UserStatus.active;
|
||||
|
||||
// Create new user entity
|
||||
final newUser = User(
|
||||
userId: 'user_${DateTime.now().millisecondsSinceEpoch}',
|
||||
phoneNumber: data.phoneNumber,
|
||||
fullName: data.fullName,
|
||||
email: data.email,
|
||||
role: data.role,
|
||||
status: initialStatus,
|
||||
loyaltyTier: LoyaltyTier.gold, // Default tier for new users
|
||||
totalPoints: 0, // New users start with 0 points
|
||||
companyInfo: data.companyName != null || data.taxCode != null
|
||||
? CompanyInfo(
|
||||
name: data.companyName,
|
||||
taxId: data.taxCode,
|
||||
businessType: _getBusinessType(data.role),
|
||||
)
|
||||
: null,
|
||||
cccd: data.cccd,
|
||||
attachments: data.attachments ?? [],
|
||||
address: data.city,
|
||||
avatarUrl: null,
|
||||
referralCode: 'REF${data.phoneNumber.substring(0, 6)}',
|
||||
referredBy: null,
|
||||
erpnextCustomerId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastLoginAt: null, // Not logged in yet
|
||||
);
|
||||
|
||||
return newUser;
|
||||
});
|
||||
}
|
||||
|
||||
/// Reset registration state
|
||||
///
|
||||
/// Clears the registration result. Useful when navigating away
|
||||
/// from success screen or starting a new registration.
|
||||
Future<void> reset() async {
|
||||
state = const AsyncValue.data(null);
|
||||
}
|
||||
|
||||
/// Get business type based on user role
|
||||
String _getBusinessType(UserRole role) {
|
||||
switch (role) {
|
||||
case UserRole.customer:
|
||||
return 'Đại lý/Thầu thợ/Kiến trúc sư';
|
||||
case UserRole.sales:
|
||||
return 'Nhân viên kinh doanh';
|
||||
case UserRole.admin:
|
||||
return 'Quản trị viên';
|
||||
case UserRole.accountant:
|
||||
return 'Kế toán';
|
||||
case UserRole.designer:
|
||||
return 'Thiết kế';
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if registration is in progress
|
||||
bool get isLoading => state.isLoading;
|
||||
|
||||
/// Get registration error if any
|
||||
Object? get error => state.error;
|
||||
|
||||
/// Get registered user if successful
|
||||
User? get registeredUser => state.value;
|
||||
|
||||
/// Check if registration was successful
|
||||
bool get isSuccess => state.hasValue && state.value != null;
|
||||
}
|
||||
|
||||
/// Convenience provider for checking if registration is in progress
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isRegistering = ref.watch(isRegisteringProvider);
|
||||
/// if (isRegistering) {
|
||||
/// // Show loading indicator
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
bool isRegistering(Ref ref) {
|
||||
final registerState = ref.watch(registerProvider);
|
||||
return registerState.isLoading;
|
||||
}
|
||||
|
||||
/// Convenience provider for checking if registration was successful
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final success = ref.watch(registrationSuccessProvider);
|
||||
/// if (success) {
|
||||
/// // Navigate to pending approval or OTP screen
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
bool registrationSuccess(Ref ref) {
|
||||
final registerState = ref.watch(registerProvider);
|
||||
return registerState.hasValue && registerState.value != null;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'register_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Registration State Provider
|
||||
///
|
||||
/// Main provider for user registration state management.
|
||||
/// Handles registration process with role-based validation.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final registerState = ref.watch(registerProvider);
|
||||
/// registerState.when(
|
||||
/// data: (user) => SuccessScreen(user),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
@ProviderFor(Register)
|
||||
const registerProvider = RegisterProvider._();
|
||||
|
||||
/// Registration State Provider
|
||||
///
|
||||
/// Main provider for user registration state management.
|
||||
/// Handles registration process with role-based validation.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final registerState = ref.watch(registerProvider);
|
||||
/// registerState.when(
|
||||
/// data: (user) => SuccessScreen(user),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
final class RegisterProvider extends $AsyncNotifierProvider<Register, User?> {
|
||||
/// Registration State Provider
|
||||
///
|
||||
/// Main provider for user registration state management.
|
||||
/// Handles registration process with role-based validation.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final registerState = ref.watch(registerProvider);
|
||||
/// registerState.when(
|
||||
/// data: (user) => SuccessScreen(user),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
const RegisterProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'registerProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$registerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
Register create() => Register();
|
||||
}
|
||||
|
||||
String _$registerHash() => r'a073b5c5958b74c63a3cddfec7f6f018e14a5088';
|
||||
|
||||
/// Registration State Provider
|
||||
///
|
||||
/// Main provider for user registration state management.
|
||||
/// Handles registration process with role-based validation.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final registerState = ref.watch(registerProvider);
|
||||
/// registerState.when(
|
||||
/// data: (user) => SuccessScreen(user),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
abstract class _$Register extends $AsyncNotifier<User?> {
|
||||
FutureOr<User?> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<AsyncValue<User?>, User?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<User?>, User?>,
|
||||
AsyncValue<User?>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience provider for checking if registration is in progress
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isRegistering = ref.watch(isRegisteringProvider);
|
||||
/// if (isRegistering) {
|
||||
/// // Show loading indicator
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(isRegistering)
|
||||
const isRegisteringProvider = IsRegisteringProvider._();
|
||||
|
||||
/// Convenience provider for checking if registration is in progress
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isRegistering = ref.watch(isRegisteringProvider);
|
||||
/// if (isRegistering) {
|
||||
/// // Show loading indicator
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
final class IsRegisteringProvider extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
/// Convenience provider for checking if registration is in progress
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isRegistering = ref.watch(isRegisteringProvider);
|
||||
/// if (isRegistering) {
|
||||
/// // Show loading indicator
|
||||
/// }
|
||||
/// ```
|
||||
const IsRegisteringProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'isRegisteringProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$isRegisteringHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
return isRegistering(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$isRegisteringHash() => r'2108b87b37451de9aaf799f9b8b380924bed2c87';
|
||||
|
||||
/// Convenience provider for checking if registration was successful
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final success = ref.watch(registrationSuccessProvider);
|
||||
/// if (success) {
|
||||
/// // Navigate to pending approval or OTP screen
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(registrationSuccess)
|
||||
const registrationSuccessProvider = RegistrationSuccessProvider._();
|
||||
|
||||
/// Convenience provider for checking if registration was successful
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final success = ref.watch(registrationSuccessProvider);
|
||||
/// if (success) {
|
||||
/// // Navigate to pending approval or OTP screen
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
final class RegistrationSuccessProvider
|
||||
extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
/// Convenience provider for checking if registration was successful
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final success = ref.watch(registrationSuccessProvider);
|
||||
/// if (success) {
|
||||
/// // Navigate to pending approval or OTP screen
|
||||
/// }
|
||||
/// ```
|
||||
const RegistrationSuccessProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'registrationSuccessProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$registrationSuccessHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
return registrationSuccess(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$registrationSuccessHash() =>
|
||||
r'6435b9ca4bf4c287497a39077a5d4558e0515ddc';
|
||||
@@ -0,0 +1,175 @@
|
||||
/// Selected Role State Provider
|
||||
///
|
||||
/// Manages the selected user role during registration.
|
||||
/// Simple state provider for role selection in the registration form.
|
||||
///
|
||||
/// Uses Riverpod 3.0 with code generation for type-safe state management.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/auth/domain/entities/user.dart';
|
||||
|
||||
part 'selected_role_provider.g.dart';
|
||||
|
||||
/// Selected Role Provider
|
||||
///
|
||||
/// Manages the currently selected user role in the registration form.
|
||||
/// Provides methods to select and clear role selection.
|
||||
///
|
||||
/// This provider is used to:
|
||||
/// - Track which role the user has selected
|
||||
/// - Conditionally show/hide verification fields based on role
|
||||
/// - Validate required documents for dealer/worker roles
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// // Watch the selected role
|
||||
/// final selectedRole = ref.watch(selectedRoleProvider);
|
||||
///
|
||||
/// // Select a role
|
||||
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
|
||||
///
|
||||
/// // Clear selection
|
||||
/// ref.read(selectedRoleProvider.notifier).clearRole();
|
||||
///
|
||||
/// // Show verification section conditionally
|
||||
/// if (selectedRole == UserRole.customer) {
|
||||
/// VerificationSection(),
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
class SelectedRole extends _$SelectedRole {
|
||||
/// Initialize with no role selected
|
||||
@override
|
||||
UserRole? build() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Select a user role
|
||||
///
|
||||
/// Updates the state with the newly selected role.
|
||||
/// This triggers UI updates that depend on role selection.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [role]: The user role to select
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// // User selects "Đại lý hệ thống" (dealer)
|
||||
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
|
||||
/// // This will show verification fields
|
||||
/// ```
|
||||
void selectRole(UserRole role) {
|
||||
state = role;
|
||||
}
|
||||
|
||||
/// Clear the role selection
|
||||
///
|
||||
/// Resets the state to null (no role selected).
|
||||
/// Useful when resetting the form or canceling registration.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// // User clicks "Cancel" or goes back
|
||||
/// ref.read(selectedRoleProvider.notifier).clearRole();
|
||||
/// // This will hide verification fields
|
||||
/// ```
|
||||
void clearRole() {
|
||||
state = null;
|
||||
}
|
||||
|
||||
/// Check if a role is currently selected
|
||||
///
|
||||
/// Returns true if any role has been selected, false otherwise.
|
||||
bool get hasSelection => state != null;
|
||||
|
||||
/// Check if verification is required for current role
|
||||
///
|
||||
/// Returns true if the selected role requires verification documents
|
||||
/// (CCCD, certificates, etc.). Currently only customer role requires this.
|
||||
///
|
||||
/// This is used to conditionally show the verification section:
|
||||
/// ```dart
|
||||
/// if (ref.read(selectedRoleProvider.notifier).requiresVerification) {
|
||||
/// // Show CCCD input, file uploads, etc.
|
||||
/// }
|
||||
/// ```
|
||||
bool get requiresVerification => state == UserRole.customer;
|
||||
|
||||
/// Get the display name for the current role (Vietnamese)
|
||||
///
|
||||
/// Returns a user-friendly Vietnamese name for the selected role,
|
||||
/// or null if no role is selected.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// final displayName = ref.read(selectedRoleProvider.notifier).displayName;
|
||||
/// // Returns: "Đại lý hệ thống" for customer role
|
||||
/// ```
|
||||
String? get displayName {
|
||||
if (state == null) return null;
|
||||
|
||||
switch (state!) {
|
||||
case UserRole.customer:
|
||||
return 'Đại lý/Thầu thợ/Kiến trúc sư';
|
||||
case UserRole.sales:
|
||||
return 'Nhân viên kinh doanh';
|
||||
case UserRole.admin:
|
||||
return 'Quản trị viên';
|
||||
case UserRole.accountant:
|
||||
return 'Kế toán';
|
||||
case UserRole.designer:
|
||||
return 'Thiết kế';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience provider for checking if verification is required
|
||||
///
|
||||
/// Returns true if the currently selected role requires verification
|
||||
/// documents (CCCD, certificates, etc.).
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final needsVerification = ref.watch(requiresVerificationProvider);
|
||||
/// if (needsVerification) {
|
||||
/// // Show verification section with file uploads
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
bool requiresVerification(Ref ref) {
|
||||
final selectedRole = ref.watch(selectedRoleProvider);
|
||||
return selectedRole == UserRole.customer;
|
||||
}
|
||||
|
||||
/// Convenience provider for getting role display name
|
||||
///
|
||||
/// Returns a user-friendly Vietnamese name for the selected role,
|
||||
/// or null if no role is selected.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final roleName = ref.watch(roleDisplayNameProvider);
|
||||
/// if (roleName != null) {
|
||||
/// Text('Bạn đang đăng ký với vai trò: $roleName');
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
String? roleDisplayName(Ref ref) {
|
||||
final selectedRole = ref.watch(selectedRoleProvider);
|
||||
|
||||
if (selectedRole == null) return null;
|
||||
|
||||
switch (selectedRole) {
|
||||
case UserRole.customer:
|
||||
return 'Đại lý/Thầu thợ/Kiến trúc sư';
|
||||
case UserRole.sales:
|
||||
return 'Nhân viên kinh doanh';
|
||||
case UserRole.admin:
|
||||
return 'Quản trị viên';
|
||||
case UserRole.accountant:
|
||||
return 'Kế toán';
|
||||
case UserRole.designer:
|
||||
return 'Thiết kế';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'selected_role_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Selected Role Provider
|
||||
///
|
||||
/// Manages the currently selected user role in the registration form.
|
||||
/// Provides methods to select and clear role selection.
|
||||
///
|
||||
/// This provider is used to:
|
||||
/// - Track which role the user has selected
|
||||
/// - Conditionally show/hide verification fields based on role
|
||||
/// - Validate required documents for dealer/worker roles
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// // Watch the selected role
|
||||
/// final selectedRole = ref.watch(selectedRoleProvider);
|
||||
///
|
||||
/// // Select a role
|
||||
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
|
||||
///
|
||||
/// // Clear selection
|
||||
/// ref.read(selectedRoleProvider.notifier).clearRole();
|
||||
///
|
||||
/// // Show verification section conditionally
|
||||
/// if (selectedRole == UserRole.customer) {
|
||||
/// VerificationSection(),
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(SelectedRole)
|
||||
const selectedRoleProvider = SelectedRoleProvider._();
|
||||
|
||||
/// Selected Role Provider
|
||||
///
|
||||
/// Manages the currently selected user role in the registration form.
|
||||
/// Provides methods to select and clear role selection.
|
||||
///
|
||||
/// This provider is used to:
|
||||
/// - Track which role the user has selected
|
||||
/// - Conditionally show/hide verification fields based on role
|
||||
/// - Validate required documents for dealer/worker roles
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// // Watch the selected role
|
||||
/// final selectedRole = ref.watch(selectedRoleProvider);
|
||||
///
|
||||
/// // Select a role
|
||||
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
|
||||
///
|
||||
/// // Clear selection
|
||||
/// ref.read(selectedRoleProvider.notifier).clearRole();
|
||||
///
|
||||
/// // Show verification section conditionally
|
||||
/// if (selectedRole == UserRole.customer) {
|
||||
/// VerificationSection(),
|
||||
/// }
|
||||
/// ```
|
||||
final class SelectedRoleProvider
|
||||
extends $NotifierProvider<SelectedRole, UserRole?> {
|
||||
/// Selected Role Provider
|
||||
///
|
||||
/// Manages the currently selected user role in the registration form.
|
||||
/// Provides methods to select and clear role selection.
|
||||
///
|
||||
/// This provider is used to:
|
||||
/// - Track which role the user has selected
|
||||
/// - Conditionally show/hide verification fields based on role
|
||||
/// - Validate required documents for dealer/worker roles
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// // Watch the selected role
|
||||
/// final selectedRole = ref.watch(selectedRoleProvider);
|
||||
///
|
||||
/// // Select a role
|
||||
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
|
||||
///
|
||||
/// // Clear selection
|
||||
/// ref.read(selectedRoleProvider.notifier).clearRole();
|
||||
///
|
||||
/// // Show verification section conditionally
|
||||
/// if (selectedRole == UserRole.customer) {
|
||||
/// VerificationSection(),
|
||||
/// }
|
||||
/// ```
|
||||
const SelectedRoleProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'selectedRoleProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$selectedRoleHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SelectedRole create() => SelectedRole();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(UserRole? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<UserRole?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$selectedRoleHash() => r'098c7fdaec4694d14a48c049556960eb6ed2dc06';
|
||||
|
||||
/// Selected Role Provider
|
||||
///
|
||||
/// Manages the currently selected user role in the registration form.
|
||||
/// Provides methods to select and clear role selection.
|
||||
///
|
||||
/// This provider is used to:
|
||||
/// - Track which role the user has selected
|
||||
/// - Conditionally show/hide verification fields based on role
|
||||
/// - Validate required documents for dealer/worker roles
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// // Watch the selected role
|
||||
/// final selectedRole = ref.watch(selectedRoleProvider);
|
||||
///
|
||||
/// // Select a role
|
||||
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
|
||||
///
|
||||
/// // Clear selection
|
||||
/// ref.read(selectedRoleProvider.notifier).clearRole();
|
||||
///
|
||||
/// // Show verification section conditionally
|
||||
/// if (selectedRole == UserRole.customer) {
|
||||
/// VerificationSection(),
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
abstract class _$SelectedRole extends $Notifier<UserRole?> {
|
||||
UserRole? build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<UserRole?, UserRole?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<UserRole?, UserRole?>,
|
||||
UserRole?,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience provider for checking if verification is required
|
||||
///
|
||||
/// Returns true if the currently selected role requires verification
|
||||
/// documents (CCCD, certificates, etc.).
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final needsVerification = ref.watch(requiresVerificationProvider);
|
||||
/// if (needsVerification) {
|
||||
/// // Show verification section with file uploads
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(requiresVerification)
|
||||
const requiresVerificationProvider = RequiresVerificationProvider._();
|
||||
|
||||
/// Convenience provider for checking if verification is required
|
||||
///
|
||||
/// Returns true if the currently selected role requires verification
|
||||
/// documents (CCCD, certificates, etc.).
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final needsVerification = ref.watch(requiresVerificationProvider);
|
||||
/// if (needsVerification) {
|
||||
/// // Show verification section with file uploads
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
final class RequiresVerificationProvider
|
||||
extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
/// Convenience provider for checking if verification is required
|
||||
///
|
||||
/// Returns true if the currently selected role requires verification
|
||||
/// documents (CCCD, certificates, etc.).
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final needsVerification = ref.watch(requiresVerificationProvider);
|
||||
/// if (needsVerification) {
|
||||
/// // Show verification section with file uploads
|
||||
/// }
|
||||
/// ```
|
||||
const RequiresVerificationProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'requiresVerificationProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$requiresVerificationHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
return requiresVerification(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$requiresVerificationHash() =>
|
||||
r'400b4242bca2defd14e46361d2b77dd94a4e3e5e';
|
||||
|
||||
/// Convenience provider for getting role display name
|
||||
///
|
||||
/// Returns a user-friendly Vietnamese name for the selected role,
|
||||
/// or null if no role is selected.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final roleName = ref.watch(roleDisplayNameProvider);
|
||||
/// if (roleName != null) {
|
||||
/// Text('Bạn đang đăng ký với vai trò: $roleName');
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(roleDisplayName)
|
||||
const roleDisplayNameProvider = RoleDisplayNameProvider._();
|
||||
|
||||
/// Convenience provider for getting role display name
|
||||
///
|
||||
/// Returns a user-friendly Vietnamese name for the selected role,
|
||||
/// or null if no role is selected.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final roleName = ref.watch(roleDisplayNameProvider);
|
||||
/// if (roleName != null) {
|
||||
/// Text('Bạn đang đăng ký với vai trò: $roleName');
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
final class RoleDisplayNameProvider
|
||||
extends $FunctionalProvider<String?, String?, String?>
|
||||
with $Provider<String?> {
|
||||
/// Convenience provider for getting role display name
|
||||
///
|
||||
/// Returns a user-friendly Vietnamese name for the selected role,
|
||||
/// or null if no role is selected.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final roleName = ref.watch(roleDisplayNameProvider);
|
||||
/// if (roleName != null) {
|
||||
/// Text('Bạn đang đăng ký với vai trò: $roleName');
|
||||
/// }
|
||||
/// ```
|
||||
const RoleDisplayNameProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'roleDisplayNameProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$roleDisplayNameHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<String?> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
String? create(Ref ref) {
|
||||
return roleDisplayName(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(String? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<String?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$roleDisplayNameHash() => r'6cb4bfd9e76fb2f3ed52d4a249e5a2477bc6f39e';
|
||||
216
lib/features/auth/presentation/widgets/file_upload_card.dart
Normal file
216
lib/features/auth/presentation/widgets/file_upload_card.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
/// File Upload Card Widget
|
||||
///
|
||||
/// Reusable widget for uploading image files with preview.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
|
||||
/// File Upload Card
|
||||
///
|
||||
/// A reusable widget for uploading files with preview functionality.
|
||||
/// Features:
|
||||
/// - Dashed border upload area
|
||||
/// - Camera/file icon
|
||||
/// - Title and subtitle
|
||||
/// - Image preview after selection
|
||||
/// - Remove button
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// FileUploadCard(
|
||||
/// file: selectedFile,
|
||||
/// onTap: () => pickImage(),
|
||||
/// onRemove: () => removeImage(),
|
||||
/// icon: Icons.camera_alt,
|
||||
/// title: 'Chụp ảnh hoặc chọn file',
|
||||
/// subtitle: 'JPG, PNG tối đa 5MB',
|
||||
/// )
|
||||
/// ```
|
||||
class FileUploadCard extends StatelessWidget {
|
||||
/// Creates a file upload card
|
||||
const FileUploadCard({
|
||||
super.key,
|
||||
required this.file,
|
||||
required this.onTap,
|
||||
required this.onRemove,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
});
|
||||
|
||||
/// Selected file (null if not selected)
|
||||
final File? file;
|
||||
|
||||
/// Callback when upload area is tapped
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Callback to remove selected file
|
||||
final VoidCallback onRemove;
|
||||
|
||||
/// Icon to display in upload area
|
||||
final IconData icon;
|
||||
|
||||
/// Title text
|
||||
final String title;
|
||||
|
||||
/// Subtitle text
|
||||
final String subtitle;
|
||||
|
||||
/// Format file size in bytes to human-readable string
|
||||
String _formatFileSize(int bytes) {
|
||||
if (bytes == 0) return '0 B';
|
||||
const suffixes = ['B', 'KB', 'MB', 'GB'];
|
||||
final i = (bytes.bitLength - 1) ~/ 10;
|
||||
final size = bytes / (1 << (i * 10));
|
||||
return '${size.toStringAsFixed(2)} ${suffixes[i]}';
|
||||
}
|
||||
|
||||
/// Get file name from path
|
||||
String _getFileName(String path) {
|
||||
return path.split('/').last;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (file != null) {
|
||||
// Show preview with remove option
|
||||
return _buildPreview(context);
|
||||
} else {
|
||||
// Show upload area
|
||||
return _buildUploadArea(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build upload area
|
||||
Widget _buildUploadArea(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
border: Border.all(
|
||||
color: const Color(0xFFCBD5E1),
|
||||
width: 2,
|
||||
strokeAlign: BorderSide.strokeAlignInside,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
children: [
|
||||
// Icon
|
||||
Icon(icon, size: 32, color: AppColors.grey500),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
// Title
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
|
||||
// Subtitle
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build preview with remove button
|
||||
Widget _buildPreview(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
border: Border.all(color: AppColors.grey100, width: 1),
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
// Thumbnail
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
child: Image.file(
|
||||
file!,
|
||||
width: 50,
|
||||
height: 50,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
color: AppColors.grey100,
|
||||
child: const Icon(
|
||||
Icons.broken_image,
|
||||
color: AppColors.grey500,
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
|
||||
// File info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_getFileName(file!.path),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
FutureBuilder<int>(
|
||||
future: file!.length(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text(
|
||||
_formatFileSize(snapshot.data!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
|
||||
// Remove button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: AppColors.danger, size: 20),
|
||||
onPressed: onRemove,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
splashRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
133
lib/features/auth/presentation/widgets/phone_input_field.dart
Normal file
133
lib/features/auth/presentation/widgets/phone_input_field.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
/// Phone Input Field Widget
|
||||
///
|
||||
/// Custom text field for Vietnamese phone number input.
|
||||
/// Supports formats: 0xxx xxx xxx, +84xxx xxx xxx, 84xxx xxx xxx
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
|
||||
/// Phone Input Field
|
||||
///
|
||||
/// A custom text field widget specifically designed for Vietnamese phone number input.
|
||||
/// Features:
|
||||
/// - Phone icon prefix
|
||||
/// - Numeric keyboard
|
||||
/// - Phone number formatting
|
||||
/// - Vietnamese phone validation support
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// PhoneInputField(
|
||||
/// controller: phoneController,
|
||||
/// validator: Validators.phone,
|
||||
/// onChanged: (value) {
|
||||
/// // Handle phone number change
|
||||
/// },
|
||||
/// )
|
||||
/// ```
|
||||
class PhoneInputField extends StatelessWidget {
|
||||
/// Creates a phone input field
|
||||
const PhoneInputField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.validator,
|
||||
this.onChanged,
|
||||
this.onFieldSubmitted,
|
||||
this.enabled = true,
|
||||
this.autofocus = false,
|
||||
});
|
||||
|
||||
/// Text editing controller
|
||||
final TextEditingController controller;
|
||||
|
||||
/// Focus node for keyboard focus management
|
||||
final FocusNode? focusNode;
|
||||
|
||||
/// Form field validator
|
||||
final String? Function(String?)? validator;
|
||||
|
||||
/// Callback when text changes
|
||||
final void Function(String)? onChanged;
|
||||
|
||||
/// Callback when field is submitted
|
||||
final void Function(String)? onFieldSubmitted;
|
||||
|
||||
/// Whether the field is enabled
|
||||
final bool enabled;
|
||||
|
||||
/// Whether the field should auto-focus
|
||||
final bool autofocus;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
autofocus: autofocus,
|
||||
enabled: enabled,
|
||||
keyboardType: TextInputType.phone,
|
||||
textInputAction: TextInputAction.next,
|
||||
inputFormatters: [
|
||||
// Allow digits, spaces, +, and parentheses
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[0-9+\s()]')),
|
||||
// Limit to reasonable phone length
|
||||
LengthLimitingTextInputFormatter(15),
|
||||
],
|
||||
style: const TextStyle(
|
||||
fontSize: InputFieldSpecs.fontSize,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Số điện thoại',
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: InputFieldSpecs.labelFontSize,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
hintText: 'Nhập số điện thoại',
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: InputFieldSpecs.hintFontSize,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.phone,
|
||||
color: AppColors.primaryBlue,
|
||||
size: AppIconSize.md,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.white,
|
||||
contentPadding: InputFieldSpecs.contentPadding,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.primaryBlue,
|
||||
width: 2.0,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.danger, width: 1.0),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.danger, width: 2.0),
|
||||
),
|
||||
errorStyle: const TextStyle(fontSize: 12.0, color: AppColors.danger),
|
||||
),
|
||||
validator: validator,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/features/auth/presentation/widgets/role_dropdown.dart
Normal file
115
lib/features/auth/presentation/widgets/role_dropdown.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
/// Role Dropdown Widget
|
||||
///
|
||||
/// Dropdown for selecting user role during registration.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
|
||||
/// Role Dropdown
|
||||
///
|
||||
/// A custom dropdown widget for selecting user role.
|
||||
/// Roles:
|
||||
/// - dealer: Đại lý hệ thống
|
||||
/// - worker: Kiến trúc sư/ Thầu thợ
|
||||
/// - broker: Khách lẻ
|
||||
/// - other: Khác
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// RoleDropdown(
|
||||
/// value: selectedRole,
|
||||
/// onChanged: (value) {
|
||||
/// setState(() {
|
||||
/// selectedRole = value;
|
||||
/// });
|
||||
/// },
|
||||
/// validator: (value) {
|
||||
/// if (value == null || value.isEmpty) {
|
||||
/// return 'Vui lòng chọn vai trò';
|
||||
/// }
|
||||
/// return null;
|
||||
/// },
|
||||
/// )
|
||||
/// ```
|
||||
class RoleDropdown extends StatelessWidget {
|
||||
/// Creates a role dropdown
|
||||
const RoleDropdown({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.validator,
|
||||
});
|
||||
|
||||
/// Selected role value
|
||||
final String? value;
|
||||
|
||||
/// Callback when role changes
|
||||
final void Function(String?) onChanged;
|
||||
|
||||
/// Form field validator
|
||||
final String? Function(String?)? validator;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DropdownButtonFormField<String>(
|
||||
value: value,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Chọn vai trò của bạn',
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: InputFieldSpecs.hintFontSize,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.work,
|
||||
color: AppColors.primaryBlue,
|
||||
size: AppIconSize.md,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.white,
|
||||
contentPadding: InputFieldSpecs.contentPadding,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.primaryBlue,
|
||||
width: 2.0,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.danger, width: 1.0),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.danger, width: 2.0),
|
||||
),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'dealer', child: Text('Đại lý hệ thống')),
|
||||
DropdownMenuItem(
|
||||
value: 'worker',
|
||||
child: Text('Kiến trúc sư/ Thầu thợ'),
|
||||
),
|
||||
DropdownMenuItem(value: 'broker', child: Text('Khách lẻ')),
|
||||
DropdownMenuItem(value: 'other', child: Text('Khác')),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
validator: validator,
|
||||
icon: const Icon(Icons.arrow_drop_down, color: AppColors.grey500),
|
||||
dropdownColor: AppColors.white,
|
||||
style: const TextStyle(
|
||||
fontSize: InputFieldSpecs.fontSize,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -76,12 +76,7 @@ class Cart {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
cartId,
|
||||
userId,
|
||||
totalAmount,
|
||||
isSynced,
|
||||
);
|
||||
return Object.hash(cartId, userId, totalAmount, isSynced);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -48,19 +48,14 @@ class _CartPageState extends ConsumerState<CartPage> {
|
||||
title: const Text('Xóa giỏ hàng'),
|
||||
content: const Text('Bạn có chắc chắn muốn xóa toàn bộ giỏ hàng?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('Hủy'),
|
||||
),
|
||||
TextButton(onPressed: () => context.pop(), child: const Text('Hủy')),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.read(cartProvider.notifier).clearCart();
|
||||
context.pop();
|
||||
context.pop(); // Also go back from cart page
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.danger,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppColors.danger),
|
||||
child: const Text('Xóa'),
|
||||
),
|
||||
],
|
||||
@@ -86,7 +81,10 @@ class _CartPageState extends ConsumerState<CartPage> {
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.black),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text('Giỏ hàng ($itemCount)', style: const TextStyle(color: Colors.black)),
|
||||
title: Text(
|
||||
'Giỏ hàng ($itemCount)',
|
||||
style: const TextStyle(color: Colors.black),
|
||||
),
|
||||
elevation: AppBarSpecs.elevation,
|
||||
backgroundColor: AppColors.white,
|
||||
foregroundColor: AppColors.grey900,
|
||||
@@ -155,9 +153,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Hãy thêm sản phẩm vào giỏ hàng',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
@@ -283,9 +279,9 @@ class _CartPageState extends ConsumerState<CartPage> {
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (_discountController.text.isNotEmpty) {
|
||||
ref.read(cartProvider.notifier).applyDiscountCode(
|
||||
_discountController.text,
|
||||
);
|
||||
ref
|
||||
.read(cartProvider.notifier)
|
||||
.applyDiscountCode(_discountController.text);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
@@ -326,7 +322,10 @@ class _CartPageState extends ConsumerState<CartPage> {
|
||||
}
|
||||
|
||||
/// Build order summary section
|
||||
Widget _buildOrderSummary(CartState cartState, NumberFormat currencyFormatter) {
|
||||
Widget _buildOrderSummary(
|
||||
CartState cartState,
|
||||
NumberFormat currencyFormatter,
|
||||
) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -394,10 +393,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Phí vận chuyển',
|
||||
style: AppTypography.bodyMedium,
|
||||
),
|
||||
Text('Phí vận chuyển', style: AppTypography.bodyMedium),
|
||||
Text(
|
||||
cartState.shippingFee > 0
|
||||
? currencyFormatter.format(cartState.shippingFee)
|
||||
@@ -448,10 +444,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
||||
: null,
|
||||
child: const Text(
|
||||
'Tiến hành đặt hàng',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -142,11 +142,10 @@ class CheckoutPage extends HookConsumerWidget {
|
||||
|
||||
// Payment Method Section (hidden if negotiation is checked)
|
||||
if (!needsNegotiation.value)
|
||||
PaymentMethodSection(
|
||||
paymentMethod: paymentMethod,
|
||||
),
|
||||
PaymentMethodSection(paymentMethod: paymentMethod),
|
||||
|
||||
if (!needsNegotiation.value) const SizedBox(height: AppSpacing.md),
|
||||
if (!needsNegotiation.value)
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Order Summary Section
|
||||
OrderSummarySection(
|
||||
@@ -160,9 +159,7 @@ class CheckoutPage extends HookConsumerWidget {
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Price Negotiation Section
|
||||
PriceNegotiationSection(
|
||||
needsNegotiation: needsNegotiation,
|
||||
),
|
||||
PriceNegotiationSection(needsNegotiation: needsNegotiation),
|
||||
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
|
||||
@@ -44,14 +44,9 @@ class Cart extends _$Cart {
|
||||
);
|
||||
} else {
|
||||
// Add new item
|
||||
final newItem = CartItemData(
|
||||
product: product,
|
||||
quantity: quantity,
|
||||
);
|
||||
final newItem = CartItemData(product: product, quantity: quantity);
|
||||
|
||||
state = state.copyWith(
|
||||
items: [...state.items, newItem],
|
||||
);
|
||||
state = state.copyWith(items: [...state.items, newItem]);
|
||||
_recalculateTotal();
|
||||
}
|
||||
}
|
||||
@@ -59,7 +54,9 @@ class Cart extends _$Cart {
|
||||
/// Remove product from cart
|
||||
void removeFromCart(String productId) {
|
||||
state = state.copyWith(
|
||||
items: state.items.where((item) => item.product.productId != productId).toList(),
|
||||
items: state.items
|
||||
.where((item) => item.product.productId != productId)
|
||||
.toList(),
|
||||
);
|
||||
_recalculateTotal();
|
||||
}
|
||||
@@ -113,20 +110,14 @@ class Cart extends _$Cart {
|
||||
// TODO: Validate with backend
|
||||
// For now, simulate discount application
|
||||
if (code.isNotEmpty) {
|
||||
state = state.copyWith(
|
||||
discountCode: code,
|
||||
discountCodeApplied: true,
|
||||
);
|
||||
state = state.copyWith(discountCode: code, discountCodeApplied: true);
|
||||
_recalculateTotal();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove discount code
|
||||
void removeDiscountCode() {
|
||||
state = state.copyWith(
|
||||
discountCode: null,
|
||||
discountCodeApplied: false,
|
||||
);
|
||||
state = state.copyWith(discountCode: null, discountCodeApplied: false);
|
||||
_recalculateTotal();
|
||||
}
|
||||
|
||||
@@ -157,10 +148,7 @@ class Cart extends _$Cart {
|
||||
|
||||
/// Get total quantity of all items
|
||||
double get totalQuantity {
|
||||
return state.items.fold<double>(
|
||||
0.0,
|
||||
(sum, item) => sum + item.quantity,
|
||||
);
|
||||
return state.items.fold<double>(0.0, (sum, item) => sum + item.quantity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,18 +12,12 @@ class CartItemData {
|
||||
final Product product;
|
||||
final double quantity;
|
||||
|
||||
const CartItemData({
|
||||
required this.product,
|
||||
required this.quantity,
|
||||
});
|
||||
const CartItemData({required this.product, required this.quantity});
|
||||
|
||||
/// Calculate line total
|
||||
double get lineTotal => product.basePrice * quantity;
|
||||
|
||||
CartItemData copyWith({
|
||||
Product? product,
|
||||
double? quantity,
|
||||
}) {
|
||||
CartItemData copyWith({Product? product, double? quantity}) {
|
||||
return CartItemData(
|
||||
product: product ?? this.product,
|
||||
quantity: quantity ?? this.quantity,
|
||||
@@ -101,7 +95,8 @@ class CartState {
|
||||
discountCode: discountCode ?? this.discountCode,
|
||||
discountCodeApplied: discountCodeApplied ?? this.discountCodeApplied,
|
||||
memberTier: memberTier ?? this.memberTier,
|
||||
memberDiscountPercent: memberDiscountPercent ?? this.memberDiscountPercent,
|
||||
memberDiscountPercent:
|
||||
memberDiscountPercent ?? this.memberDiscountPercent,
|
||||
subtotal: subtotal ?? this.subtotal,
|
||||
memberDiscount: memberDiscount ?? this.memberDiscount,
|
||||
shippingFee: shippingFee ?? this.shippingFee,
|
||||
|
||||
@@ -22,10 +22,7 @@ import 'package:worker/features/cart/presentation/providers/cart_state.dart';
|
||||
class CartItemWidget extends ConsumerWidget {
|
||||
final CartItemData item;
|
||||
|
||||
const CartItemWidget({
|
||||
super.key,
|
||||
required this.item,
|
||||
});
|
||||
const CartItemWidget({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -65,9 +62,7 @@ class CartItemWidget extends ConsumerWidget {
|
||||
height: 80,
|
||||
color: AppColors.grey100,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
@@ -129,9 +124,9 @@ class CartItemWidget extends ConsumerWidget {
|
||||
_QuantityButton(
|
||||
icon: Icons.remove,
|
||||
onPressed: () {
|
||||
ref.read(cartProvider.notifier).decrementQuantity(
|
||||
item.product.productId,
|
||||
);
|
||||
ref
|
||||
.read(cartProvider.notifier)
|
||||
.decrementQuantity(item.product.productId);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -151,9 +146,9 @@ class CartItemWidget extends ConsumerWidget {
|
||||
_QuantityButton(
|
||||
icon: Icons.add,
|
||||
onPressed: () {
|
||||
ref.read(cartProvider.notifier).incrementQuantity(
|
||||
item.product.productId,
|
||||
);
|
||||
ref
|
||||
.read(cartProvider.notifier)
|
||||
.incrementQuantity(item.product.productId);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -184,10 +179,7 @@ class _QuantityButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _QuantityButton({
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
});
|
||||
const _QuantityButton({required this.icon, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -201,11 +193,7 @@ class _QuantityButton extends StatelessWidget {
|
||||
color: AppColors.grey100,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
child: Icon(icon, size: 18, color: AppColors.grey900),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,8 +68,11 @@ class CheckoutDatePickerField extends HookWidget {
|
||||
: AppColors.grey500.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.calendar_today,
|
||||
size: 20, color: AppColors.grey500),
|
||||
const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 20,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -75,10 +75,7 @@ class CheckoutDropdownField extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
items: items.map((item) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: item,
|
||||
child: Text(item),
|
||||
);
|
||||
return DropdownMenuItem<String>(value: item, child: Text(item));
|
||||
}).toList(),
|
||||
onChanged: onChanged,
|
||||
validator: (value) {
|
||||
|
||||
@@ -114,7 +114,8 @@ class CheckoutSubmitButton extends StatelessWidget {
|
||||
});
|
||||
} else {
|
||||
// Generate order ID (mock - replace with actual from backend)
|
||||
final orderId = 'DH${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}';
|
||||
final orderId =
|
||||
'DH${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}';
|
||||
|
||||
// Show order success message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -130,10 +131,7 @@ class CheckoutSubmitButton extends StatelessWidget {
|
||||
if (context.mounted) {
|
||||
context.pushNamed(
|
||||
RouteNames.paymentQr,
|
||||
queryParameters: {
|
||||
'orderId': orderId,
|
||||
'amount': total.toString(),
|
||||
},
|
||||
queryParameters: {'orderId': orderId, 'amount': total.toString()},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -103,13 +103,7 @@ class DeliveryInformationSection extends HookWidget {
|
||||
label: 'Tỉnh/Thành phố',
|
||||
value: selectedProvince.value,
|
||||
required: true,
|
||||
items: const [
|
||||
'TP.HCM',
|
||||
'Hà Nội',
|
||||
'Đà Nẵng',
|
||||
'Cần Thơ',
|
||||
'Biên Hòa',
|
||||
],
|
||||
items: const ['TP.HCM', 'Hà Nội', 'Đà Nẵng', 'Cần Thơ', 'Biên Hòa'],
|
||||
onChanged: (value) {
|
||||
selectedProvince.value = value;
|
||||
},
|
||||
|
||||
@@ -132,8 +132,9 @@ class InvoiceSection extends HookWidget {
|
||||
return 'Vui lòng nhập email';
|
||||
}
|
||||
if (needsInvoice.value &&
|
||||
!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
|
||||
.hasMatch(value!)) {
|
||||
!RegExp(
|
||||
r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$',
|
||||
).hasMatch(value!)) {
|
||||
return 'Email không hợp lệ';
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -148,8 +148,10 @@ class OrderSummarySection extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Mã: ${item['sku']}',
|
||||
style:
|
||||
const TextStyle(fontSize: 12, color: AppColors.grey500),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -168,8 +170,9 @@ class OrderSummarySection extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatCurrency(
|
||||
((item['price'] as int) * (item['quantity'] as int))
|
||||
.toDouble()),
|
||||
((item['price'] as int) * (item['quantity'] as int))
|
||||
.toDouble(),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -184,8 +187,11 @@ class OrderSummarySection extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// Build summary row
|
||||
Widget _buildSummaryRow(String label, double amount,
|
||||
{bool isDiscount = false}) {
|
||||
Widget _buildSummaryRow(
|
||||
String label,
|
||||
double amount, {
|
||||
bool isDiscount = false,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -207,9 +213,6 @@ class OrderSummarySection extends StatelessWidget {
|
||||
|
||||
/// Format currency
|
||||
String _formatCurrency(double amount) {
|
||||
return '${amount.toStringAsFixed(0).replaceAllMapped(
|
||||
RegExp(r'(\d)(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]}.',
|
||||
)}₫';
|
||||
return '${amount.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}₫';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,7 @@ import 'package:worker/core/theme/colors.dart';
|
||||
class PaymentMethodSection extends HookWidget {
|
||||
final ValueNotifier<String> paymentMethod;
|
||||
|
||||
const PaymentMethodSection({
|
||||
super.key,
|
||||
required this.paymentMethod,
|
||||
});
|
||||
const PaymentMethodSection({super.key, required this.paymentMethod});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -72,13 +69,17 @@ class PaymentMethodSection extends HookWidget {
|
||||
Text(
|
||||
'Chuyển khoản ngân hàng',
|
||||
style: TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w500),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Thanh toán qua chuyển khoản',
|
||||
style:
|
||||
TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -112,13 +113,17 @@ class PaymentMethodSection extends HookWidget {
|
||||
Text(
|
||||
'Thanh toán khi nhận hàng (COD)',
|
||||
style: TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w500),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Thanh toán bằng tiền mặt khi nhận hàng',
|
||||
style:
|
||||
TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -14,10 +14,7 @@ import 'package:worker/core/theme/colors.dart';
|
||||
class PriceNegotiationSection extends HookWidget {
|
||||
final ValueNotifier<bool> needsNegotiation;
|
||||
|
||||
const PriceNegotiationSection({
|
||||
super.key,
|
||||
required this.needsNegotiation,
|
||||
});
|
||||
const PriceNegotiationSection({super.key, required this.needsNegotiation});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -7,18 +7,39 @@ part 'chat_room_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.chatRoomModel)
|
||||
class ChatRoomModel extends HiveObject {
|
||||
ChatRoomModel({required this.chatRoomId, required this.roomType, this.relatedQuoteId, this.relatedOrderId, required this.participants, this.roomName, required this.isActive, this.lastActivity, required this.createdAt, this.createdBy});
|
||||
|
||||
@HiveField(0) final String chatRoomId;
|
||||
@HiveField(1) final RoomType roomType;
|
||||
@HiveField(2) final String? relatedQuoteId;
|
||||
@HiveField(3) final String? relatedOrderId;
|
||||
@HiveField(4) final String participants;
|
||||
@HiveField(5) final String? roomName;
|
||||
@HiveField(6) final bool isActive;
|
||||
@HiveField(7) final DateTime? lastActivity;
|
||||
@HiveField(8) final DateTime createdAt;
|
||||
@HiveField(9) final String? createdBy;
|
||||
ChatRoomModel({
|
||||
required this.chatRoomId,
|
||||
required this.roomType,
|
||||
this.relatedQuoteId,
|
||||
this.relatedOrderId,
|
||||
required this.participants,
|
||||
this.roomName,
|
||||
required this.isActive,
|
||||
this.lastActivity,
|
||||
required this.createdAt,
|
||||
this.createdBy,
|
||||
});
|
||||
|
||||
@HiveField(0)
|
||||
final String chatRoomId;
|
||||
@HiveField(1)
|
||||
final RoomType roomType;
|
||||
@HiveField(2)
|
||||
final String? relatedQuoteId;
|
||||
@HiveField(3)
|
||||
final String? relatedOrderId;
|
||||
@HiveField(4)
|
||||
final String participants;
|
||||
@HiveField(5)
|
||||
final String? roomName;
|
||||
@HiveField(6)
|
||||
final bool isActive;
|
||||
@HiveField(7)
|
||||
final DateTime? lastActivity;
|
||||
@HiveField(8)
|
||||
final DateTime createdAt;
|
||||
@HiveField(9)
|
||||
final String? createdBy;
|
||||
|
||||
factory ChatRoomModel.fromJson(Map<String, dynamic> json) => ChatRoomModel(
|
||||
chatRoomId: json['chat_room_id'] as String,
|
||||
@@ -28,7 +49,9 @@ class ChatRoomModel extends HiveObject {
|
||||
participants: jsonEncode(json['participants']),
|
||||
roomName: json['room_name'] as String?,
|
||||
isActive: json['is_active'] as bool? ?? true,
|
||||
lastActivity: json['last_activity'] != null ? DateTime.parse(json['last_activity']?.toString() ?? '') : null,
|
||||
lastActivity: json['last_activity'] != null
|
||||
? DateTime.parse(json['last_activity']?.toString() ?? '')
|
||||
: null,
|
||||
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||
createdBy: json['created_by'] as String?,
|
||||
);
|
||||
|
||||
@@ -7,27 +7,56 @@ part 'message_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.messageModel)
|
||||
class MessageModel extends HiveObject {
|
||||
MessageModel({required this.messageId, required this.chatRoomId, required this.senderId, required this.contentType, required this.content, this.attachmentUrl, this.productReference, required this.isRead, required this.isEdited, required this.isDeleted, this.readBy, required this.timestamp, this.editedAt});
|
||||
|
||||
@HiveField(0) final String messageId;
|
||||
@HiveField(1) final String chatRoomId;
|
||||
@HiveField(2) final String senderId;
|
||||
@HiveField(3) final ContentType contentType;
|
||||
@HiveField(4) final String content;
|
||||
@HiveField(5) final String? attachmentUrl;
|
||||
@HiveField(6) final String? productReference;
|
||||
@HiveField(7) final bool isRead;
|
||||
@HiveField(8) final bool isEdited;
|
||||
@HiveField(9) final bool isDeleted;
|
||||
@HiveField(10) final String? readBy;
|
||||
@HiveField(11) final DateTime timestamp;
|
||||
@HiveField(12) final DateTime? editedAt;
|
||||
MessageModel({
|
||||
required this.messageId,
|
||||
required this.chatRoomId,
|
||||
required this.senderId,
|
||||
required this.contentType,
|
||||
required this.content,
|
||||
this.attachmentUrl,
|
||||
this.productReference,
|
||||
required this.isRead,
|
||||
required this.isEdited,
|
||||
required this.isDeleted,
|
||||
this.readBy,
|
||||
required this.timestamp,
|
||||
this.editedAt,
|
||||
});
|
||||
|
||||
@HiveField(0)
|
||||
final String messageId;
|
||||
@HiveField(1)
|
||||
final String chatRoomId;
|
||||
@HiveField(2)
|
||||
final String senderId;
|
||||
@HiveField(3)
|
||||
final ContentType contentType;
|
||||
@HiveField(4)
|
||||
final String content;
|
||||
@HiveField(5)
|
||||
final String? attachmentUrl;
|
||||
@HiveField(6)
|
||||
final String? productReference;
|
||||
@HiveField(7)
|
||||
final bool isRead;
|
||||
@HiveField(8)
|
||||
final bool isEdited;
|
||||
@HiveField(9)
|
||||
final bool isDeleted;
|
||||
@HiveField(10)
|
||||
final String? readBy;
|
||||
@HiveField(11)
|
||||
final DateTime timestamp;
|
||||
@HiveField(12)
|
||||
final DateTime? editedAt;
|
||||
|
||||
factory MessageModel.fromJson(Map<String, dynamic> json) => MessageModel(
|
||||
messageId: json['message_id'] as String,
|
||||
chatRoomId: json['chat_room_id'] as String,
|
||||
senderId: json['sender_id'] as String,
|
||||
contentType: ContentType.values.firstWhere((e) => e.name == json['content_type']),
|
||||
contentType: ContentType.values.firstWhere(
|
||||
(e) => e.name == json['content_type'],
|
||||
),
|
||||
content: json['content'] as String,
|
||||
attachmentUrl: json['attachment_url'] as String?,
|
||||
productReference: json['product_reference'] as String?,
|
||||
@@ -36,7 +65,9 @@ class MessageModel extends HiveObject {
|
||||
isDeleted: json['is_deleted'] as bool? ?? false,
|
||||
readBy: json['read_by'] != null ? jsonEncode(json['read_by']) : null,
|
||||
timestamp: DateTime.parse(json['timestamp']?.toString() ?? ''),
|
||||
editedAt: json['edited_at'] != null ? DateTime.parse(json['edited_at']?.toString() ?? '') : null,
|
||||
editedAt: json['edited_at'] != null
|
||||
? DateTime.parse(json['edited_at']?.toString() ?? '')
|
||||
: null,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
|
||||
@@ -117,8 +117,7 @@ class Message {
|
||||
bool get isSystemMessage => contentType == ContentType.system;
|
||||
|
||||
/// Check if message has attachment
|
||||
bool get hasAttachment =>
|
||||
attachmentUrl != null && attachmentUrl!.isNotEmpty;
|
||||
bool get hasAttachment => attachmentUrl != null && attachmentUrl!.isNotEmpty;
|
||||
|
||||
/// Check if message references a product
|
||||
bool get hasProductReference =>
|
||||
|
||||
@@ -112,10 +112,16 @@ class _ChatListPageState extends ConsumerState<ChatListPage> {
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Tìm kiếm cuộc trò chuyện...',
|
||||
prefixIcon: const Icon(Icons.search, color: AppColors.grey500),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, color: AppColors.grey500),
|
||||
icon: const Icon(
|
||||
Icons.clear,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_searchController.clear();
|
||||
@@ -148,7 +154,10 @@ class _ChatListPageState extends ConsumerState<ChatListPage> {
|
||||
// Conversation 1 - Order Reference
|
||||
_ConversationItem(
|
||||
avatarIcon: Icons.inventory_2,
|
||||
avatarGradient: const [AppColors.primaryBlue, AppColors.lightBlue],
|
||||
avatarGradient: const [
|
||||
AppColors.primaryBlue,
|
||||
AppColors.lightBlue,
|
||||
],
|
||||
contactName: 'Đơn hàng #SO001234',
|
||||
messageTime: '14:30',
|
||||
lastMessage: 'Đơn hàng đang được giao - Dự kiến đến 16:00',
|
||||
@@ -183,7 +192,10 @@ class _ChatListPageState extends ConsumerState<ChatListPage> {
|
||||
// Conversation 3 - Support Team
|
||||
_ConversationItem(
|
||||
avatarIcon: Icons.headset_mic,
|
||||
avatarGradient: const [AppColors.primaryBlue, AppColors.lightBlue],
|
||||
avatarGradient: const [
|
||||
AppColors.primaryBlue,
|
||||
AppColors.lightBlue,
|
||||
],
|
||||
contactName: 'Tổng đài hỗ trợ',
|
||||
messageTime: '13:45',
|
||||
lastMessage: 'Thông tin về quy trình đổi trả sản phẩm',
|
||||
@@ -319,8 +331,8 @@ class _ConversationItem extends StatelessWidget {
|
||||
color: isOnline
|
||||
? AppColors.success
|
||||
: isAway
|
||||
? AppColors.warning
|
||||
: AppColors.grey500,
|
||||
? AppColors.warning
|
||||
: AppColors.grey500,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.white, width: 2),
|
||||
),
|
||||
|
||||
@@ -40,7 +40,9 @@ class FavoritesLocalDataSource {
|
||||
Future<void> addFavorite(FavoriteModel favorite) async {
|
||||
try {
|
||||
await _box.put(favorite.favoriteId, favorite);
|
||||
debugPrint('[FavoritesLocalDataSource] Added favorite: ${favorite.favoriteId} for user: ${favorite.userId}');
|
||||
debugPrint(
|
||||
'[FavoritesLocalDataSource] Added favorite: ${favorite.favoriteId} for user: ${favorite.userId}',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[FavoritesLocalDataSource] Error adding favorite: $e');
|
||||
rethrow;
|
||||
@@ -60,13 +62,17 @@ class FavoritesLocalDataSource {
|
||||
.toList();
|
||||
|
||||
if (favorites.isEmpty) {
|
||||
debugPrint('[FavoritesLocalDataSource] Favorite not found: productId=$productId, userId=$userId');
|
||||
debugPrint(
|
||||
'[FavoritesLocalDataSource] Favorite not found: productId=$productId, userId=$userId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
final favorite = favorites.first;
|
||||
await _box.delete(favorite.favoriteId);
|
||||
debugPrint('[FavoritesLocalDataSource] Removed favorite: ${favorite.favoriteId} for user: $userId');
|
||||
debugPrint(
|
||||
'[FavoritesLocalDataSource] Removed favorite: ${favorite.favoriteId} for user: $userId',
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[FavoritesLocalDataSource] Error removing favorite: $e');
|
||||
@@ -79,9 +85,9 @@ class FavoritesLocalDataSource {
|
||||
/// Returns true if the product is in the user's favorites, false otherwise.
|
||||
bool isFavorite(String productId, String userId) {
|
||||
try {
|
||||
return _box.values
|
||||
.whereType<FavoriteModel>()
|
||||
.any((fav) => fav.productId == productId && fav.userId == userId);
|
||||
return _box.values.whereType<FavoriteModel>().any(
|
||||
(fav) => fav.productId == productId && fav.userId == userId,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[FavoritesLocalDataSource] Error checking favorite: $e');
|
||||
return false;
|
||||
@@ -101,7 +107,9 @@ class FavoritesLocalDataSource {
|
||||
.toList();
|
||||
|
||||
await _box.deleteAll(favoriteIds);
|
||||
debugPrint('[FavoritesLocalDataSource] Cleared ${favoriteIds.length} favorites for user: $userId');
|
||||
debugPrint(
|
||||
'[FavoritesLocalDataSource] Cleared ${favoriteIds.length} favorites for user: $userId',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[FavoritesLocalDataSource] Error clearing favorites: $e');
|
||||
rethrow;
|
||||
@@ -140,7 +148,9 @@ class FavoritesLocalDataSource {
|
||||
debugPrint('[FavoritesLocalDataSource] Favorites box compacted');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[FavoritesLocalDataSource] Error compacting favorites box: $e');
|
||||
debugPrint(
|
||||
'[FavoritesLocalDataSource] Error compacting favorites box: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,11 +62,6 @@ class Favorite {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
favoriteId,
|
||||
productId,
|
||||
userId,
|
||||
createdAt,
|
||||
);
|
||||
return Object.hash(favoriteId, productId, userId, createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,11 @@ class FavoritesPage extends ConsumerWidget {
|
||||
const FavoritesPage({super.key});
|
||||
|
||||
/// Show confirmation dialog before clearing all favorites
|
||||
Future<void> _showClearAllDialog(BuildContext context, WidgetRef ref, int count) async {
|
||||
Future<void> _showClearAllDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
int count,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -185,10 +189,7 @@ class _EmptyState extends StatelessWidget {
|
||||
// Subtext
|
||||
Text(
|
||||
'Thêm sản phẩm vào danh sách yêu thích để xem lại sau',
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 14.0, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
@@ -213,10 +214,7 @@ class _EmptyState extends StatelessWidget {
|
||||
),
|
||||
child: const Text(
|
||||
'Khám phá sản phẩm',
|
||||
style: TextStyle(
|
||||
fontSize: 16.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -351,10 +349,7 @@ class _ErrorState extends StatelessWidget {
|
||||
final Object error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const _ErrorState({
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
});
|
||||
const _ErrorState({required this.error, required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -389,10 +384,7 @@ class _ErrorState extends StatelessWidget {
|
||||
// Error message
|
||||
Text(
|
||||
error.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -417,10 +409,7 @@ class _ErrorState extends StatelessWidget {
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text(
|
||||
'Thử lại',
|
||||
style: TextStyle(
|
||||
fontSize: 16.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -440,9 +429,7 @@ class _ErrorState extends StatelessWidget {
|
||||
class _FavoritesGrid extends StatelessWidget {
|
||||
final List<Product> products;
|
||||
|
||||
const _FavoritesGrid({
|
||||
required this.products,
|
||||
});
|
||||
const _FavoritesGrid({required this.products});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -457,9 +444,7 @@ class _FavoritesGrid extends StatelessWidget {
|
||||
itemCount: products.length,
|
||||
itemBuilder: (context, index) {
|
||||
final product = products[index];
|
||||
return RepaintBoundary(
|
||||
child: FavoriteProductCard(product: product),
|
||||
);
|
||||
return RepaintBoundary(child: FavoriteProductCard(product: product));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,7 +260,9 @@ Future<List<Product>> favoriteProducts(Ref ref) async {
|
||||
final allProducts = await getProductsUseCase();
|
||||
|
||||
// Filter to only include favorited products
|
||||
return allProducts.where((product) => favoriteIds.contains(product.productId)).toList();
|
||||
return allProducts
|
||||
.where((product) => favoriteIds.contains(product.productId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -22,10 +22,7 @@ import 'package:worker/features/products/domain/entities/product.dart';
|
||||
class FavoriteProductCard extends ConsumerWidget {
|
||||
final Product product;
|
||||
|
||||
const FavoriteProductCard({
|
||||
super.key,
|
||||
required this.product,
|
||||
});
|
||||
const FavoriteProductCard({super.key, required this.product});
|
||||
|
||||
String _formatPrice(double price) {
|
||||
final formatter = NumberFormat('#,###', 'vi_VN');
|
||||
@@ -60,7 +57,9 @@ class FavoriteProductCard extends ConsumerWidget {
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
// Remove from favorites
|
||||
await ref.read(favoritesProvider.notifier).removeFavorite(product.productId);
|
||||
await ref
|
||||
.read(favoritesProvider.notifier)
|
||||
.removeFavorite(product.productId);
|
||||
|
||||
// Show snackbar
|
||||
if (context.mounted) {
|
||||
@@ -103,9 +102,7 @@ class FavoriteProductCard extends ConsumerWidget {
|
||||
placeholder: (context, url) => Shimmer.fromColors(
|
||||
baseColor: AppColors.grey100,
|
||||
highlightColor: AppColors.grey50,
|
||||
child: Container(
|
||||
color: AppColors.grey100,
|
||||
),
|
||||
child: Container(color: AppColors.grey100),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: AppColors.grey100,
|
||||
|
||||
@@ -82,7 +82,7 @@ class HomeLocalDataSourceImpl implements HomeLocalDataSource {
|
||||
'tier': 'diamond',
|
||||
'points': 9750,
|
||||
'validUntil': '2025-12-31T23:59:59.000Z',
|
||||
'qrData': '0983441099'
|
||||
'qrData': '0983441099',
|
||||
};
|
||||
|
||||
/// Mock JSON data for promotions
|
||||
@@ -115,7 +115,7 @@ class HomeLocalDataSourceImpl implements HomeLocalDataSource {
|
||||
'startDate': '2025-01-01T00:00:00.000Z',
|
||||
'endDate': '2025-12-31T23:59:59.000Z',
|
||||
'discountPercentage': 5,
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/// Constructor
|
||||
|
||||
@@ -15,7 +15,7 @@ enum PromotionStatus {
|
||||
upcoming,
|
||||
|
||||
/// Expired promotion
|
||||
expired;
|
||||
expired,
|
||||
}
|
||||
|
||||
/// Promotion Entity
|
||||
|
||||
@@ -222,7 +222,6 @@ class HomePage extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,7 @@ class MemberCardWidget extends StatelessWidget {
|
||||
/// Member card data
|
||||
final MemberCard memberCard;
|
||||
|
||||
const MemberCardWidget({
|
||||
super.key,
|
||||
required this.memberCard,
|
||||
});
|
||||
const MemberCardWidget({super.key, required this.memberCard});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -185,8 +182,8 @@ class MemberCardWidget extends StatelessWidget {
|
||||
/// Format points with thousands separator
|
||||
String _formatPoints(int points) {
|
||||
return points.toString().replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]},',
|
||||
);
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]},',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@ import 'package:worker/features/home/domain/entities/promotion.dart';
|
||||
/// Displays a horizontal scrollable list of promotion cards.
|
||||
/// Each card shows an image, title, and brief description.
|
||||
class PromotionSlider extends StatelessWidget {
|
||||
|
||||
const PromotionSlider({
|
||||
super.key,
|
||||
required this.promotions,
|
||||
this.onPromotionTap,
|
||||
});
|
||||
|
||||
/// List of promotions to display
|
||||
final List<Promotion> promotions;
|
||||
|
||||
@@ -83,11 +83,7 @@ class PromotionSlider extends StatelessWidget {
|
||||
|
||||
/// Individual Promotion Card
|
||||
class _PromotionCard extends StatelessWidget {
|
||||
|
||||
const _PromotionCard({
|
||||
required this.promotion,
|
||||
this.onTap,
|
||||
});
|
||||
const _PromotionCard({required this.promotion, this.onTap});
|
||||
final Promotion promotion;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@@ -115,8 +111,9 @@ class _PromotionCard extends StatelessWidget {
|
||||
children: [
|
||||
// Promotion Image
|
||||
ClipRRect(
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: promotion.imageUrl,
|
||||
height: 140,
|
||||
@@ -125,9 +122,7 @@ class _PromotionCard extends StatelessWidget {
|
||||
placeholder: (context, url) => Container(
|
||||
height: 140,
|
||||
color: AppColors.grey100,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
height: 140,
|
||||
|
||||
@@ -63,11 +63,7 @@ class QuickActionItem extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Icon
|
||||
Icon(
|
||||
icon,
|
||||
size: 32,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
Icon(icon, size: 32, color: AppColors.primaryBlue),
|
||||
const SizedBox(height: 8),
|
||||
// Label
|
||||
Text(
|
||||
@@ -97,9 +93,7 @@ class QuickActionItem extends StatelessWidget {
|
||||
color: AppColors.danger,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 20,
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 20),
|
||||
child: Text(
|
||||
badge!,
|
||||
style: const TextStyle(
|
||||
|
||||
@@ -31,9 +31,7 @@ class PointsHistoryLocalDataSource {
|
||||
/// Get all points entries
|
||||
Future<List<LoyaltyPointEntryModel>> getAllEntries() async {
|
||||
final box = await entriesBox;
|
||||
final entries = box.values
|
||||
.whereType<LoyaltyPointEntryModel>()
|
||||
.toList();
|
||||
final entries = box.values.whereType<LoyaltyPointEntryModel>().toList();
|
||||
entries.sort((a, b) => b.timestamp.compareTo(a.timestamp)); // Newest first
|
||||
return entries;
|
||||
}
|
||||
@@ -42,9 +40,9 @@ class PointsHistoryLocalDataSource {
|
||||
Future<LoyaltyPointEntryModel?> getEntryById(String entryId) async {
|
||||
final box = await entriesBox;
|
||||
try {
|
||||
return box.values
|
||||
.whereType<LoyaltyPointEntryModel>()
|
||||
.firstWhere((entry) => entry.entryId == entryId);
|
||||
return box.values.whereType<LoyaltyPointEntryModel>().firstWhere(
|
||||
(entry) => entry.entryId == entryId,
|
||||
);
|
||||
} catch (e) {
|
||||
throw Exception('Entry not found');
|
||||
}
|
||||
|
||||
@@ -6,41 +6,81 @@ part 'gift_catalog_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.giftCatalogModel)
|
||||
class GiftCatalogModel extends HiveObject {
|
||||
GiftCatalogModel({required this.catalogId, required this.name, required this.description, this.imageUrl, required this.category, required this.pointsCost, required this.cashValue, required this.quantityAvailable, required this.quantityRedeemed, this.termsConditions, required this.isActive, this.validFrom, this.validUntil, required this.createdAt, this.updatedAt});
|
||||
|
||||
@HiveField(0) final String catalogId;
|
||||
@HiveField(1) final String name;
|
||||
@HiveField(2) final String description;
|
||||
@HiveField(3) final String? imageUrl;
|
||||
@HiveField(4) final GiftCategory category;
|
||||
@HiveField(5) final int pointsCost;
|
||||
@HiveField(6) final double cashValue;
|
||||
@HiveField(7) final int quantityAvailable;
|
||||
@HiveField(8) final int quantityRedeemed;
|
||||
@HiveField(9) final String? termsConditions;
|
||||
@HiveField(10) final bool isActive;
|
||||
@HiveField(11) final DateTime? validFrom;
|
||||
@HiveField(12) final DateTime? validUntil;
|
||||
@HiveField(13) final DateTime createdAt;
|
||||
@HiveField(14) final DateTime? updatedAt;
|
||||
GiftCatalogModel({
|
||||
required this.catalogId,
|
||||
required this.name,
|
||||
required this.description,
|
||||
this.imageUrl,
|
||||
required this.category,
|
||||
required this.pointsCost,
|
||||
required this.cashValue,
|
||||
required this.quantityAvailable,
|
||||
required this.quantityRedeemed,
|
||||
this.termsConditions,
|
||||
required this.isActive,
|
||||
this.validFrom,
|
||||
this.validUntil,
|
||||
required this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory GiftCatalogModel.fromJson(Map<String, dynamic> json) => GiftCatalogModel(
|
||||
catalogId: json['catalog_id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
category: GiftCategory.values.firstWhere((e) => e.name == json['category']),
|
||||
pointsCost: json['points_cost'] as int,
|
||||
cashValue: (json['cash_value'] as num).toDouble(),
|
||||
quantityAvailable: json['quantity_available'] as int,
|
||||
quantityRedeemed: json['quantity_redeemed'] as int? ?? 0,
|
||||
termsConditions: json['terms_conditions'] as String?,
|
||||
isActive: json['is_active'] as bool? ?? true,
|
||||
validFrom: json['valid_from'] != null ? DateTime.parse(json['valid_from']?.toString() ?? '') : null,
|
||||
validUntil: json['valid_until'] != null ? DateTime.parse(json['valid_until']?.toString() ?? '') : null,
|
||||
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||
updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']?.toString() ?? '') : null,
|
||||
);
|
||||
@HiveField(0)
|
||||
final String catalogId;
|
||||
@HiveField(1)
|
||||
final String name;
|
||||
@HiveField(2)
|
||||
final String description;
|
||||
@HiveField(3)
|
||||
final String? imageUrl;
|
||||
@HiveField(4)
|
||||
final GiftCategory category;
|
||||
@HiveField(5)
|
||||
final int pointsCost;
|
||||
@HiveField(6)
|
||||
final double cashValue;
|
||||
@HiveField(7)
|
||||
final int quantityAvailable;
|
||||
@HiveField(8)
|
||||
final int quantityRedeemed;
|
||||
@HiveField(9)
|
||||
final String? termsConditions;
|
||||
@HiveField(10)
|
||||
final bool isActive;
|
||||
@HiveField(11)
|
||||
final DateTime? validFrom;
|
||||
@HiveField(12)
|
||||
final DateTime? validUntil;
|
||||
@HiveField(13)
|
||||
final DateTime createdAt;
|
||||
@HiveField(14)
|
||||
final DateTime? updatedAt;
|
||||
|
||||
factory GiftCatalogModel.fromJson(Map<String, dynamic> json) =>
|
||||
GiftCatalogModel(
|
||||
catalogId: json['catalog_id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
category: GiftCategory.values.firstWhere(
|
||||
(e) => e.name == json['category'],
|
||||
),
|
||||
pointsCost: json['points_cost'] as int,
|
||||
cashValue: (json['cash_value'] as num).toDouble(),
|
||||
quantityAvailable: json['quantity_available'] as int,
|
||||
quantityRedeemed: json['quantity_redeemed'] as int? ?? 0,
|
||||
termsConditions: json['terms_conditions'] as String?,
|
||||
isActive: json['is_active'] as bool? ?? true,
|
||||
validFrom: json['valid_from'] != null
|
||||
? DateTime.parse(json['valid_from']?.toString() ?? '')
|
||||
: null,
|
||||
validUntil: json['valid_until'] != null
|
||||
? DateTime.parse(json['valid_until']?.toString() ?? '')
|
||||
: null,
|
||||
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at']?.toString() ?? '')
|
||||
: null,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'catalog_id': catalogId,
|
||||
|
||||
@@ -7,24 +7,55 @@ part 'loyalty_point_entry_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.loyaltyPointEntryModel)
|
||||
class LoyaltyPointEntryModel extends HiveObject {
|
||||
LoyaltyPointEntryModel({required this.entryId, required this.userId, required this.points, required this.entryType, required this.source, required this.description, this.referenceId, this.referenceType, this.complaint, required this.complaintStatus, required this.balanceAfter, this.expiryDate, required this.timestamp, this.erpnextEntryId});
|
||||
|
||||
@HiveField(0) final String entryId;
|
||||
@HiveField(1) final String userId;
|
||||
@HiveField(2) final int points;
|
||||
@HiveField(3) final EntryType entryType;
|
||||
@HiveField(4) final EntrySource source;
|
||||
@HiveField(5) final String description;
|
||||
@HiveField(6) final String? referenceId;
|
||||
@HiveField(7) final String? referenceType;
|
||||
@HiveField(8) final String? complaint;
|
||||
@HiveField(9) final ComplaintStatus complaintStatus;
|
||||
@HiveField(10) final int balanceAfter;
|
||||
@HiveField(11) final DateTime? expiryDate;
|
||||
@HiveField(12) final DateTime timestamp;
|
||||
@HiveField(13) final String? erpnextEntryId;
|
||||
LoyaltyPointEntryModel({
|
||||
required this.entryId,
|
||||
required this.userId,
|
||||
required this.points,
|
||||
required this.entryType,
|
||||
required this.source,
|
||||
required this.description,
|
||||
this.referenceId,
|
||||
this.referenceType,
|
||||
this.complaint,
|
||||
required this.complaintStatus,
|
||||
required this.balanceAfter,
|
||||
this.expiryDate,
|
||||
required this.timestamp,
|
||||
this.erpnextEntryId,
|
||||
});
|
||||
|
||||
factory LoyaltyPointEntryModel.fromJson(Map<String, dynamic> json) => LoyaltyPointEntryModel(
|
||||
@HiveField(0)
|
||||
final String entryId;
|
||||
@HiveField(1)
|
||||
final String userId;
|
||||
@HiveField(2)
|
||||
final int points;
|
||||
@HiveField(3)
|
||||
final EntryType entryType;
|
||||
@HiveField(4)
|
||||
final EntrySource source;
|
||||
@HiveField(5)
|
||||
final String description;
|
||||
@HiveField(6)
|
||||
final String? referenceId;
|
||||
@HiveField(7)
|
||||
final String? referenceType;
|
||||
@HiveField(8)
|
||||
final String? complaint;
|
||||
@HiveField(9)
|
||||
final ComplaintStatus complaintStatus;
|
||||
@HiveField(10)
|
||||
final int balanceAfter;
|
||||
@HiveField(11)
|
||||
final DateTime? expiryDate;
|
||||
@HiveField(12)
|
||||
final DateTime timestamp;
|
||||
@HiveField(13)
|
||||
final String? erpnextEntryId;
|
||||
|
||||
factory LoyaltyPointEntryModel.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => LoyaltyPointEntryModel(
|
||||
entryId: json['entry_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
points: json['points'] as int,
|
||||
@@ -34,9 +65,13 @@ class LoyaltyPointEntryModel extends HiveObject {
|
||||
referenceId: json['reference_id'] as String?,
|
||||
referenceType: json['reference_type'] as String?,
|
||||
complaint: json['complaint'] != null ? jsonEncode(json['complaint']) : null,
|
||||
complaintStatus: ComplaintStatus.values.firstWhere((e) => e.name == (json['complaint_status'] ?? 'none')),
|
||||
complaintStatus: ComplaintStatus.values.firstWhere(
|
||||
(e) => e.name == (json['complaint_status'] ?? 'none'),
|
||||
),
|
||||
balanceAfter: json['balance_after'] as int,
|
||||
expiryDate: json['expiry_date'] != null ? DateTime.parse(json['expiry_date']?.toString() ?? '') : null,
|
||||
expiryDate: json['expiry_date'] != null
|
||||
? DateTime.parse(json['expiry_date']?.toString() ?? '')
|
||||
: null,
|
||||
timestamp: DateTime.parse(json['timestamp']?.toString() ?? ''),
|
||||
erpnextEntryId: json['erpnext_entry_id'] as String?,
|
||||
);
|
||||
@@ -67,6 +102,7 @@ class LoyaltyPointEntryModel extends HiveObject {
|
||||
}
|
||||
}
|
||||
|
||||
bool get isExpired => expiryDate != null && DateTime.now().isAfter(expiryDate!);
|
||||
bool get isExpired =>
|
||||
expiryDate != null && DateTime.now().isAfter(expiryDate!);
|
||||
bool get hasComplaint => complaintStatus != ComplaintStatus.none;
|
||||
}
|
||||
|
||||
@@ -7,39 +7,75 @@ part 'points_record_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.pointsRecordModel)
|
||||
class PointsRecordModel extends HiveObject {
|
||||
PointsRecordModel({required this.recordId, required this.userId, required this.invoiceNumber, required this.storeName, required this.transactionDate, required this.invoiceAmount, this.notes, this.attachments, required this.status, this.rejectReason, this.pointsEarned, required this.submittedAt, this.processedAt, this.processedBy});
|
||||
|
||||
@HiveField(0) final String recordId;
|
||||
@HiveField(1) final String userId;
|
||||
@HiveField(2) final String invoiceNumber;
|
||||
@HiveField(3) final String storeName;
|
||||
@HiveField(4) final DateTime transactionDate;
|
||||
@HiveField(5) final double invoiceAmount;
|
||||
@HiveField(6) final String? notes;
|
||||
@HiveField(7) final String? attachments;
|
||||
@HiveField(8) final PointsStatus status;
|
||||
@HiveField(9) final String? rejectReason;
|
||||
@HiveField(10) final int? pointsEarned;
|
||||
@HiveField(11) final DateTime submittedAt;
|
||||
@HiveField(12) final DateTime? processedAt;
|
||||
@HiveField(13) final String? processedBy;
|
||||
PointsRecordModel({
|
||||
required this.recordId,
|
||||
required this.userId,
|
||||
required this.invoiceNumber,
|
||||
required this.storeName,
|
||||
required this.transactionDate,
|
||||
required this.invoiceAmount,
|
||||
this.notes,
|
||||
this.attachments,
|
||||
required this.status,
|
||||
this.rejectReason,
|
||||
this.pointsEarned,
|
||||
required this.submittedAt,
|
||||
this.processedAt,
|
||||
this.processedBy,
|
||||
});
|
||||
|
||||
factory PointsRecordModel.fromJson(Map<String, dynamic> json) => PointsRecordModel(
|
||||
recordId: json['record_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
invoiceNumber: json['invoice_number'] as String,
|
||||
storeName: json['store_name'] as String,
|
||||
transactionDate: DateTime.parse(json['transaction_date']?.toString() ?? ''),
|
||||
invoiceAmount: (json['invoice_amount'] as num).toDouble(),
|
||||
notes: json['notes'] as String?,
|
||||
attachments: json['attachments'] != null ? jsonEncode(json['attachments']) : null,
|
||||
status: PointsStatus.values.firstWhere((e) => e.name == json['status']),
|
||||
rejectReason: json['reject_reason'] as String?,
|
||||
pointsEarned: json['points_earned'] as int?,
|
||||
submittedAt: DateTime.parse(json['submitted_at']?.toString() ?? ''),
|
||||
processedAt: json['processed_at'] != null ? DateTime.parse(json['processed_at']?.toString() ?? '') : null,
|
||||
processedBy: json['processed_by'] as String?,
|
||||
);
|
||||
@HiveField(0)
|
||||
final String recordId;
|
||||
@HiveField(1)
|
||||
final String userId;
|
||||
@HiveField(2)
|
||||
final String invoiceNumber;
|
||||
@HiveField(3)
|
||||
final String storeName;
|
||||
@HiveField(4)
|
||||
final DateTime transactionDate;
|
||||
@HiveField(5)
|
||||
final double invoiceAmount;
|
||||
@HiveField(6)
|
||||
final String? notes;
|
||||
@HiveField(7)
|
||||
final String? attachments;
|
||||
@HiveField(8)
|
||||
final PointsStatus status;
|
||||
@HiveField(9)
|
||||
final String? rejectReason;
|
||||
@HiveField(10)
|
||||
final int? pointsEarned;
|
||||
@HiveField(11)
|
||||
final DateTime submittedAt;
|
||||
@HiveField(12)
|
||||
final DateTime? processedAt;
|
||||
@HiveField(13)
|
||||
final String? processedBy;
|
||||
|
||||
factory PointsRecordModel.fromJson(Map<String, dynamic> json) =>
|
||||
PointsRecordModel(
|
||||
recordId: json['record_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
invoiceNumber: json['invoice_number'] as String,
|
||||
storeName: json['store_name'] as String,
|
||||
transactionDate: DateTime.parse(
|
||||
json['transaction_date']?.toString() ?? '',
|
||||
),
|
||||
invoiceAmount: (json['invoice_amount'] as num).toDouble(),
|
||||
notes: json['notes'] as String?,
|
||||
attachments: json['attachments'] != null
|
||||
? jsonEncode(json['attachments'])
|
||||
: null,
|
||||
status: PointsStatus.values.firstWhere((e) => e.name == json['status']),
|
||||
rejectReason: json['reject_reason'] as String?,
|
||||
pointsEarned: json['points_earned'] as int?,
|
||||
submittedAt: DateTime.parse(json['submitted_at']?.toString() ?? ''),
|
||||
processedAt: json['processed_at'] != null
|
||||
? DateTime.parse(json['processed_at']?.toString() ?? '')
|
||||
: null,
|
||||
processedBy: json['processed_by'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'record_id': recordId,
|
||||
|
||||
@@ -6,43 +6,83 @@ part 'redeemed_gift_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.redeemedGiftModel)
|
||||
class RedeemedGiftModel extends HiveObject {
|
||||
RedeemedGiftModel({required this.giftId, required this.userId, required this.catalogId, required this.name, required this.description, this.voucherCode, this.qrCodeImage, required this.giftType, required this.pointsCost, required this.cashValue, this.expiryDate, required this.status, required this.redeemedAt, this.usedAt, this.usedLocation, this.usedReference});
|
||||
|
||||
@HiveField(0) final String giftId;
|
||||
@HiveField(1) final String userId;
|
||||
@HiveField(2) final String catalogId;
|
||||
@HiveField(3) final String name;
|
||||
@HiveField(4) final String description;
|
||||
@HiveField(5) final String? voucherCode;
|
||||
@HiveField(6) final String? qrCodeImage;
|
||||
@HiveField(7) final GiftCategory giftType;
|
||||
@HiveField(8) final int pointsCost;
|
||||
@HiveField(9) final double cashValue;
|
||||
@HiveField(10) final DateTime? expiryDate;
|
||||
@HiveField(11) final GiftStatus status;
|
||||
@HiveField(12) final DateTime redeemedAt;
|
||||
@HiveField(13) final DateTime? usedAt;
|
||||
@HiveField(14) final String? usedLocation;
|
||||
@HiveField(15) final String? usedReference;
|
||||
RedeemedGiftModel({
|
||||
required this.giftId,
|
||||
required this.userId,
|
||||
required this.catalogId,
|
||||
required this.name,
|
||||
required this.description,
|
||||
this.voucherCode,
|
||||
this.qrCodeImage,
|
||||
required this.giftType,
|
||||
required this.pointsCost,
|
||||
required this.cashValue,
|
||||
this.expiryDate,
|
||||
required this.status,
|
||||
required this.redeemedAt,
|
||||
this.usedAt,
|
||||
this.usedLocation,
|
||||
this.usedReference,
|
||||
});
|
||||
|
||||
factory RedeemedGiftModel.fromJson(Map<String, dynamic> json) => RedeemedGiftModel(
|
||||
giftId: json['gift_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
catalogId: json['catalog_id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
voucherCode: json['voucher_code'] as String?,
|
||||
qrCodeImage: json['qr_code_image'] as String?,
|
||||
giftType: GiftCategory.values.firstWhere((e) => e.name == json['gift_type']),
|
||||
pointsCost: json['points_cost'] as int,
|
||||
cashValue: (json['cash_value'] as num).toDouble(),
|
||||
expiryDate: json['expiry_date'] != null ? DateTime.parse(json['expiry_date']?.toString() ?? '') : null,
|
||||
status: GiftStatus.values.firstWhere((e) => e.name == json['status']),
|
||||
redeemedAt: DateTime.parse(json['redeemed_at']?.toString() ?? ''),
|
||||
usedAt: json['used_at'] != null ? DateTime.parse(json['used_at']?.toString() ?? '') : null,
|
||||
usedLocation: json['used_location'] as String?,
|
||||
usedReference: json['used_reference'] as String?,
|
||||
);
|
||||
@HiveField(0)
|
||||
final String giftId;
|
||||
@HiveField(1)
|
||||
final String userId;
|
||||
@HiveField(2)
|
||||
final String catalogId;
|
||||
@HiveField(3)
|
||||
final String name;
|
||||
@HiveField(4)
|
||||
final String description;
|
||||
@HiveField(5)
|
||||
final String? voucherCode;
|
||||
@HiveField(6)
|
||||
final String? qrCodeImage;
|
||||
@HiveField(7)
|
||||
final GiftCategory giftType;
|
||||
@HiveField(8)
|
||||
final int pointsCost;
|
||||
@HiveField(9)
|
||||
final double cashValue;
|
||||
@HiveField(10)
|
||||
final DateTime? expiryDate;
|
||||
@HiveField(11)
|
||||
final GiftStatus status;
|
||||
@HiveField(12)
|
||||
final DateTime redeemedAt;
|
||||
@HiveField(13)
|
||||
final DateTime? usedAt;
|
||||
@HiveField(14)
|
||||
final String? usedLocation;
|
||||
@HiveField(15)
|
||||
final String? usedReference;
|
||||
|
||||
factory RedeemedGiftModel.fromJson(Map<String, dynamic> json) =>
|
||||
RedeemedGiftModel(
|
||||
giftId: json['gift_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
catalogId: json['catalog_id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
voucherCode: json['voucher_code'] as String?,
|
||||
qrCodeImage: json['qr_code_image'] as String?,
|
||||
giftType: GiftCategory.values.firstWhere(
|
||||
(e) => e.name == json['gift_type'],
|
||||
),
|
||||
pointsCost: json['points_cost'] as int,
|
||||
cashValue: (json['cash_value'] as num).toDouble(),
|
||||
expiryDate: json['expiry_date'] != null
|
||||
? DateTime.parse(json['expiry_date']?.toString() ?? '')
|
||||
: null,
|
||||
status: GiftStatus.values.firstWhere((e) => e.name == json['status']),
|
||||
redeemedAt: DateTime.parse(json['redeemed_at']?.toString() ?? ''),
|
||||
usedAt: json['used_at'] != null
|
||||
? DateTime.parse(json['used_at']?.toString() ?? '')
|
||||
: null,
|
||||
usedLocation: json['used_location'] as String?,
|
||||
usedReference: json['used_reference'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'gift_id': giftId,
|
||||
@@ -63,7 +103,8 @@ class RedeemedGiftModel extends HiveObject {
|
||||
'used_reference': usedReference,
|
||||
};
|
||||
|
||||
bool get isExpired => expiryDate != null && DateTime.now().isAfter(expiryDate!);
|
||||
bool get isExpired =>
|
||||
expiryDate != null && DateTime.now().isAfter(expiryDate!);
|
||||
bool get isUsed => status == GiftStatus.used;
|
||||
bool get isActive => status == GiftStatus.active && !isExpired;
|
||||
}
|
||||
|
||||
@@ -194,13 +194,7 @@ class GiftCatalog {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
catalogId,
|
||||
name,
|
||||
category,
|
||||
pointsCost,
|
||||
isActive,
|
||||
);
|
||||
return Object.hash(catalogId, name, category, pointsCost, isActive);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -15,7 +15,7 @@ enum EntryType {
|
||||
adjustment,
|
||||
|
||||
/// Points expired
|
||||
expiry;
|
||||
expiry,
|
||||
}
|
||||
|
||||
/// Entry source enum
|
||||
@@ -45,7 +45,7 @@ enum EntrySource {
|
||||
welcome,
|
||||
|
||||
/// Other source
|
||||
other;
|
||||
other,
|
||||
}
|
||||
|
||||
/// Complaint status enum
|
||||
@@ -63,7 +63,7 @@ enum ComplaintStatus {
|
||||
approved,
|
||||
|
||||
/// Complaint rejected
|
||||
rejected;
|
||||
rejected,
|
||||
}
|
||||
|
||||
/// Loyalty Point Entry Entity
|
||||
|
||||
@@ -164,13 +164,7 @@ class PointsRecord {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
recordId,
|
||||
userId,
|
||||
invoiceNumber,
|
||||
invoiceAmount,
|
||||
status,
|
||||
);
|
||||
return Object.hash(recordId, userId, invoiceNumber, invoiceAmount, status);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -190,13 +190,7 @@ class RedeemedGift {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
giftId,
|
||||
userId,
|
||||
catalogId,
|
||||
voucherCode,
|
||||
status,
|
||||
);
|
||||
return Object.hash(giftId, userId, catalogId, voucherCode, status);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -204,9 +204,7 @@ class LoyaltyPage extends ConsumerWidget {
|
||||
return Card(
|
||||
elevation: 5,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -228,17 +226,11 @@ class LoyaltyPage extends ConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
'Hạng hiện tại: DIAMOND',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
),
|
||||
Text(
|
||||
'Hạng kế tiếp: PLATINUM',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -265,10 +257,7 @@ class LoyaltyPage extends ConsumerWidget {
|
||||
child: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: const TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
children: [
|
||||
TextSpan(text: 'Còn '),
|
||||
TextSpan(
|
||||
@@ -414,9 +403,7 @@ class LoyaltyPage extends ConsumerWidget {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -436,7 +423,10 @@ class LoyaltyPage extends ConsumerWidget {
|
||||
_buildBenefitItem('Ưu tiên xử lý đơn hàng'),
|
||||
_buildBenefitItem('Tặng 500 điểm vào ngày sinh nhật'),
|
||||
_buildBenefitItem('Tư vấn thiết kế miễn phí'),
|
||||
_buildBenefitItem('Mời tham gia sự kiện VIP độc quyền', isLast: true),
|
||||
_buildBenefitItem(
|
||||
'Mời tham gia sự kiện VIP độc quyền',
|
||||
isLast: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -450,11 +440,7 @@ class LoyaltyPage extends ConsumerWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
size: 20,
|
||||
color: Color(0xFF4A00E0),
|
||||
),
|
||||
const Icon(Icons.check_circle, size: 20, color: Color(0xFF4A00E0)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
|
||||
@@ -65,7 +65,9 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Transaction List
|
||||
...entries.map((entry) => _buildTransactionCard(context, ref, entry)),
|
||||
...entries.map(
|
||||
(entry) => _buildTransactionCard(context, ref, entry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -82,9 +84,7 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -101,20 +101,13 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.filter_list,
|
||||
color: AppColors.primaryBlue,
|
||||
size: 20,
|
||||
),
|
||||
Icon(Icons.filter_list, color: AppColors.primaryBlue, size: 20),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Thời gian hiệu lực: 01/01/2023 - 31/12/2023',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 12, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -137,14 +130,14 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
|
||||
// Get transaction amount if it's a purchase
|
||||
final datasource = ref.read(pointsHistoryLocalDataSourceProvider);
|
||||
final transactionAmount = datasource.getTransactionAmount(entry.description);
|
||||
final transactionAmount = datasource.getTransactionAmount(
|
||||
entry.description,
|
||||
);
|
||||
|
||||
return Card(
|
||||
elevation: 1,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -199,11 +192,16 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Chức năng khiếu nại đang phát triển')),
|
||||
const SnackBar(
|
||||
content: Text('Chức năng khiếu nại đang phát triển'),
|
||||
),
|
||||
);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
side: const BorderSide(color: AppColors.grey500),
|
||||
foregroundColor: AppColors.grey900,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
@@ -235,8 +233,8 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
color: entry.points > 0
|
||||
? AppColors.success
|
||||
: entry.points < 0
|
||||
? AppColors.danger
|
||||
: AppColors.grey900,
|
||||
? AppColors.danger
|
||||
: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
@@ -282,10 +280,7 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Kéo xuống để làm mới',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -316,10 +311,7 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -37,13 +37,20 @@ class RewardsPage extends ConsumerWidget {
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.black),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: const Text('Đổi quà tặng', style: TextStyle(color: Colors.black)),
|
||||
title: const Text(
|
||||
'Đổi quà tặng',
|
||||
style: TextStyle(color: Colors.black),
|
||||
),
|
||||
elevation: AppBarSpecs.elevation,
|
||||
backgroundColor: AppColors.white,
|
||||
foregroundColor: AppColors.grey900,
|
||||
centerTitle: false,
|
||||
actions: const [
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline, color: Colors.black),
|
||||
onPressed: () => _showInfoDialog(context),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
@@ -72,26 +79,20 @@ class RewardsPage extends ConsumerWidget {
|
||||
sliver: filteredGifts.isEmpty
|
||||
? _buildEmptyState()
|
||||
: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.7,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final gift = filteredGifts[index];
|
||||
return RewardCard(
|
||||
gift: gift,
|
||||
onRedeem: () => _handleRedeemGift(
|
||||
context,
|
||||
ref,
|
||||
gift,
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: filteredGifts.length,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.7,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final gift = filteredGifts[index];
|
||||
return RewardCard(
|
||||
gift: gift,
|
||||
onRedeem: () => _handleRedeemGift(context, ref, gift),
|
||||
);
|
||||
}, childCount: filteredGifts.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -100,6 +101,84 @@ class RewardsPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Show info dialog with usage instructions
|
||||
void _showInfoDialog(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text(
|
||||
'Hướng dẫn sử dụng',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Đây là nội dung hướng dẫn sử dụng cho tính năng Đổi quà tặng:',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoItem(
|
||||
'Sử dụng điểm tích lũy của bạn để đổi các phần quà giá trị trong danh mục.',
|
||||
),
|
||||
_buildInfoItem(
|
||||
'Bấm vào một phần quà để xem chi tiết và điều kiện áp dụng.',
|
||||
),
|
||||
_buildInfoItem(
|
||||
'Khi xác nhận đổi quà, bạn có thể chọn "Nhận hàng tại Showroom".',
|
||||
),
|
||||
_buildInfoItem(
|
||||
'Nếu chọn "Nhận hàng tại Showroom", bạn sẽ cần chọn Showroom bạn muốn đến nhận từ danh sách thả xuống.',
|
||||
),
|
||||
_buildInfoItem(
|
||||
'Quà đã đổi sẽ được chuyển vào mục "Quà của tôi" (trong trang Hội viên).',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 44),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('Đóng'),
|
||||
),
|
||||
],
|
||||
actionsPadding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
contentPadding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
titlePadding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build info item with bullet point
|
||||
Widget _buildInfoItem(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6),
|
||||
child: Icon(Icons.circle, size: 6, color: AppColors.grey500),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(text, style: TextStyle(fontSize: 14, height: 1.5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build category filter pills
|
||||
Widget _buildCategoryFilter(
|
||||
BuildContext context,
|
||||
@@ -237,10 +316,7 @@ class RewardsPage extends ConsumerWidget {
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Vui lòng thử lại sau',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -290,10 +366,7 @@ class RewardsPage extends ConsumerWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Chi phí:',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const Text('Chi phí:', style: TextStyle(fontSize: 13)),
|
||||
Text(
|
||||
'${numberFormat.format(gift.pointsCost)} điểm',
|
||||
style: const TextStyle(
|
||||
@@ -363,9 +436,7 @@ class RewardsPage extends ConsumerWidget {
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text('Đổi quà "${gift.name}" thành công!'),
|
||||
),
|
||||
Expanded(child: Text('Đổi quà "${gift.name}" thành công!')),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppColors.success,
|
||||
|
||||
@@ -38,7 +38,9 @@ class PointsHistory extends _$PointsHistory {
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
return await ref.read(pointsHistoryLocalDataSourceProvider).getAllEntries();
|
||||
return await ref
|
||||
.read(pointsHistoryLocalDataSourceProvider)
|
||||
.getAllEntries();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,25 +22,17 @@ class RewardCard extends ConsumerWidget {
|
||||
/// Callback when redeem button is pressed
|
||||
final VoidCallback onRedeem;
|
||||
|
||||
const RewardCard({
|
||||
required this.gift,
|
||||
required this.onRedeem,
|
||||
super.key,
|
||||
});
|
||||
const RewardCard({required this.gift, required this.onRedeem, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasEnoughPoints = ref.watch(
|
||||
hasEnoughPointsProvider(gift.pointsCost),
|
||||
);
|
||||
final hasEnoughPoints = ref.watch(hasEnoughPointsProvider(gift.pointsCost));
|
||||
final numberFormat = NumberFormat('#,###', 'vi_VN');
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -158,9 +150,7 @@ class RewardCard extends ConsumerWidget {
|
||||
placeholder: (context, url) => Container(
|
||||
color: AppColors.grey100,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
|
||||
@@ -50,7 +50,11 @@ class MainScaffold extends ConsumerWidget {
|
||||
onPressed: () => context.push(RouteNames.chat),
|
||||
backgroundColor: const Color(0xFF35C6F4), // Accent cyan color
|
||||
elevation: 4,
|
||||
child: const Icon(Icons.chat_bubble, color: AppColors.white, size: 28),
|
||||
child: const Icon(
|
||||
Icons.chat_bubble,
|
||||
color: AppColors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
@@ -58,7 +62,11 @@ class MainScaffold extends ConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, -2)),
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
@@ -75,9 +83,18 @@ class MainScaffold extends ConsumerWidget {
|
||||
currentIndex: currentIndex,
|
||||
elevation: 0,
|
||||
items: [
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Trang chủ'),
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.loyalty), label: 'Hội viên'),
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.local_offer), label: 'Tin tức'),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home),
|
||||
label: 'Trang chủ',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.loyalty),
|
||||
label: 'Hội viên',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.local_offer),
|
||||
label: 'Tin tức',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
@@ -87,12 +104,25 @@ class MainScaffold extends ConsumerWidget {
|
||||
top: -4,
|
||||
right: -4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: AppColors.danger, borderRadius: BorderRadius.circular(12)),
|
||||
constraints: const BoxConstraints(minWidth: 20, minHeight: 20),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.danger,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 20,
|
||||
minHeight: 20,
|
||||
),
|
||||
child: const Text(
|
||||
'5',
|
||||
style: TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
@@ -101,7 +131,10 @@ class MainScaffold extends ConsumerWidget {
|
||||
),
|
||||
label: 'Thông báo',
|
||||
),
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.account_circle), label: 'Cài đặt'),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.account_circle),
|
||||
label: 'Cài đặt',
|
||||
),
|
||||
],
|
||||
onTap: (index) {
|
||||
ref.read(currentPageIndexProvider.notifier).setIndex(index);
|
||||
|
||||
@@ -66,7 +66,7 @@ class NotificationLocalDataSource {
|
||||
'data': {
|
||||
'current_tier': 'gold',
|
||||
'next_tier': 'platinum',
|
||||
'points_needed': 2250
|
||||
'points_needed': 2250,
|
||||
},
|
||||
'is_read': true,
|
||||
'is_pushed': true,
|
||||
@@ -128,17 +128,20 @@ class NotificationLocalDataSource {
|
||||
|
||||
/// Get notifications by category
|
||||
Future<List<Map<String, dynamic>>> getNotificationsByCategory(
|
||||
String category) async {
|
||||
String category,
|
||||
) async {
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
if (category == 'general') {
|
||||
return _mockNotifications
|
||||
.where((n) =>
|
||||
!(n['type'] as String).toLowerCase().contains('order') ||
|
||||
(n['type'] as String).toLowerCase().contains('loyalty') ||
|
||||
(n['type'] as String).toLowerCase().contains('promotion') ||
|
||||
(n['type'] as String).toLowerCase().contains('event') ||
|
||||
(n['type'] as String).toLowerCase().contains('birthday'))
|
||||
.where(
|
||||
(n) =>
|
||||
!(n['type'] as String).toLowerCase().contains('order') ||
|
||||
(n['type'] as String).toLowerCase().contains('loyalty') ||
|
||||
(n['type'] as String).toLowerCase().contains('promotion') ||
|
||||
(n['type'] as String).toLowerCase().contains('event') ||
|
||||
(n['type'] as String).toLowerCase().contains('birthday'),
|
||||
)
|
||||
.toList();
|
||||
} else if (category == 'order') {
|
||||
return _mockNotifications
|
||||
@@ -159,8 +162,9 @@ class NotificationLocalDataSource {
|
||||
Future<void> markAsRead(String notificationId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
|
||||
final index = _mockNotifications
|
||||
.indexWhere((n) => n['notification_id'] == notificationId);
|
||||
final index = _mockNotifications.indexWhere(
|
||||
(n) => n['notification_id'] == notificationId,
|
||||
);
|
||||
if (index != -1) {
|
||||
_mockNotifications[index]['is_read'] = true;
|
||||
_mockNotifications[index]['read_at'] = DateTime.now().toIso8601String();
|
||||
|
||||
@@ -21,7 +21,7 @@ class NotificationModel {
|
||||
isRead: json['is_read'] as bool? ?? false,
|
||||
isPushed: json['is_pushed'] as bool? ?? false,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
readAt: json['read_at'] != null
|
||||
readAt: json['read_at'] != null
|
||||
? DateTime.parse(json['read_at'] as String)
|
||||
: null,
|
||||
);
|
||||
|
||||
@@ -21,7 +21,9 @@ class NotificationRepositoryImpl implements NotificationRepository {
|
||||
Future<List<Notification>> getAllNotifications() async {
|
||||
final jsonList = await localDataSource.getAllNotifications();
|
||||
return jsonList.map((json) => NotificationModel.fromJson(json)).toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt)); // Sort by newest first
|
||||
..sort(
|
||||
(a, b) => b.createdAt.compareTo(a.createdAt),
|
||||
); // Sort by newest first
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -60,7 +60,8 @@ class Notification {
|
||||
bool get isOrderNotification => type.toLowerCase().contains('order');
|
||||
|
||||
/// Check if notification is loyalty-related
|
||||
bool get isLoyaltyNotification => type.toLowerCase().contains('loyalty') ||
|
||||
bool get isLoyaltyNotification =>
|
||||
type.toLowerCase().contains('loyalty') ||
|
||||
type.toLowerCase().contains('points');
|
||||
|
||||
/// Check if notification is promotion-related
|
||||
|
||||
@@ -27,7 +27,9 @@ class InvoicesLocalDataSource {
|
||||
.map((json) => InvoiceModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
debugPrint('[InvoicesLocalDataSource] Loaded ${invoices.length} invoices');
|
||||
debugPrint(
|
||||
'[InvoicesLocalDataSource] Loaded ${invoices.length} invoices',
|
||||
);
|
||||
return invoices;
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[InvoicesLocalDataSource] Error loading invoices: $e');
|
||||
@@ -65,8 +67,11 @@ class InvoicesLocalDataSource {
|
||||
final filtered = allInvoices
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.invoiceNumber.toLowerCase().contains(query.toLowerCase()) ||
|
||||
(invoice.orderId?.toLowerCase().contains(query.toLowerCase()) ?? false),
|
||||
invoice.invoiceNumber.toLowerCase().contains(
|
||||
query.toLowerCase(),
|
||||
) ||
|
||||
(invoice.orderId?.toLowerCase().contains(query.toLowerCase()) ??
|
||||
false),
|
||||
)
|
||||
.toList();
|
||||
|
||||
@@ -89,7 +94,9 @@ class InvoicesLocalDataSource {
|
||||
orElse: () => throw Exception('Invoice not found: $invoiceId'),
|
||||
);
|
||||
|
||||
debugPrint('[InvoicesLocalDataSource] Found invoice: ${invoice.invoiceNumber}');
|
||||
debugPrint(
|
||||
'[InvoicesLocalDataSource] Found invoice: ${invoice.invoiceNumber}',
|
||||
);
|
||||
return invoice;
|
||||
} catch (e) {
|
||||
debugPrint('[InvoicesLocalDataSource] Error getting invoice: $e');
|
||||
@@ -105,10 +112,14 @@ class InvoicesLocalDataSource {
|
||||
.where((invoice) => invoice.isOverdue)
|
||||
.toList();
|
||||
|
||||
debugPrint('[InvoicesLocalDataSource] Found ${overdue.length} overdue invoices');
|
||||
debugPrint(
|
||||
'[InvoicesLocalDataSource] Found ${overdue.length} overdue invoices',
|
||||
);
|
||||
return overdue;
|
||||
} catch (e) {
|
||||
debugPrint('[InvoicesLocalDataSource] Error getting overdue invoices: $e');
|
||||
debugPrint(
|
||||
'[InvoicesLocalDataSource] Error getting overdue invoices: $e',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -118,10 +129,14 @@ class InvoicesLocalDataSource {
|
||||
try {
|
||||
final allInvoices = await getAllInvoices();
|
||||
final unpaid = allInvoices
|
||||
.where((invoice) => invoice.status.name == 'issued' && !invoice.isPaid)
|
||||
.where(
|
||||
(invoice) => invoice.status.name == 'issued' && !invoice.isPaid,
|
||||
)
|
||||
.toList();
|
||||
|
||||
debugPrint('[InvoicesLocalDataSource] Found ${unpaid.length} unpaid invoices');
|
||||
debugPrint(
|
||||
'[InvoicesLocalDataSource] Found ${unpaid.length} unpaid invoices',
|
||||
);
|
||||
return unpaid;
|
||||
} catch (e) {
|
||||
debugPrint('[InvoicesLocalDataSource] Error getting unpaid invoices: $e');
|
||||
|
||||
@@ -6,37 +6,84 @@ part 'invoice_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.invoiceModel)
|
||||
class InvoiceModel extends HiveObject {
|
||||
InvoiceModel({required this.invoiceId, required this.invoiceNumber, required this.userId, this.orderId, required this.invoiceType, required this.issueDate, required this.dueDate, required this.currency, required this.subtotalAmount, required this.taxAmount, required this.discountAmount, required this.shippingAmount, required this.totalAmount, required this.amountPaid, required this.amountRemaining, required this.status, this.paymentTerms, this.notes, this.erpnextInvoice, required this.createdAt, this.updatedAt, this.lastReminderSent});
|
||||
|
||||
@HiveField(0) final String invoiceId;
|
||||
@HiveField(1) final String invoiceNumber;
|
||||
@HiveField(2) final String userId;
|
||||
@HiveField(3) final String? orderId;
|
||||
@HiveField(4) final InvoiceType invoiceType;
|
||||
@HiveField(5) final DateTime issueDate;
|
||||
@HiveField(6) final DateTime dueDate;
|
||||
@HiveField(7) final String currency;
|
||||
@HiveField(8) final double subtotalAmount;
|
||||
@HiveField(9) final double taxAmount;
|
||||
@HiveField(10) final double discountAmount;
|
||||
@HiveField(11) final double shippingAmount;
|
||||
@HiveField(12) final double totalAmount;
|
||||
@HiveField(13) final double amountPaid;
|
||||
@HiveField(14) final double amountRemaining;
|
||||
@HiveField(15) final InvoiceStatus status;
|
||||
@HiveField(16) final String? paymentTerms;
|
||||
@HiveField(17) final String? notes;
|
||||
@HiveField(18) final String? erpnextInvoice;
|
||||
@HiveField(19) final DateTime createdAt;
|
||||
@HiveField(20) final DateTime? updatedAt;
|
||||
@HiveField(21) final DateTime? lastReminderSent;
|
||||
InvoiceModel({
|
||||
required this.invoiceId,
|
||||
required this.invoiceNumber,
|
||||
required this.userId,
|
||||
this.orderId,
|
||||
required this.invoiceType,
|
||||
required this.issueDate,
|
||||
required this.dueDate,
|
||||
required this.currency,
|
||||
required this.subtotalAmount,
|
||||
required this.taxAmount,
|
||||
required this.discountAmount,
|
||||
required this.shippingAmount,
|
||||
required this.totalAmount,
|
||||
required this.amountPaid,
|
||||
required this.amountRemaining,
|
||||
required this.status,
|
||||
this.paymentTerms,
|
||||
this.notes,
|
||||
this.erpnextInvoice,
|
||||
required this.createdAt,
|
||||
this.updatedAt,
|
||||
this.lastReminderSent,
|
||||
});
|
||||
|
||||
@HiveField(0)
|
||||
final String invoiceId;
|
||||
@HiveField(1)
|
||||
final String invoiceNumber;
|
||||
@HiveField(2)
|
||||
final String userId;
|
||||
@HiveField(3)
|
||||
final String? orderId;
|
||||
@HiveField(4)
|
||||
final InvoiceType invoiceType;
|
||||
@HiveField(5)
|
||||
final DateTime issueDate;
|
||||
@HiveField(6)
|
||||
final DateTime dueDate;
|
||||
@HiveField(7)
|
||||
final String currency;
|
||||
@HiveField(8)
|
||||
final double subtotalAmount;
|
||||
@HiveField(9)
|
||||
final double taxAmount;
|
||||
@HiveField(10)
|
||||
final double discountAmount;
|
||||
@HiveField(11)
|
||||
final double shippingAmount;
|
||||
@HiveField(12)
|
||||
final double totalAmount;
|
||||
@HiveField(13)
|
||||
final double amountPaid;
|
||||
@HiveField(14)
|
||||
final double amountRemaining;
|
||||
@HiveField(15)
|
||||
final InvoiceStatus status;
|
||||
@HiveField(16)
|
||||
final String? paymentTerms;
|
||||
@HiveField(17)
|
||||
final String? notes;
|
||||
@HiveField(18)
|
||||
final String? erpnextInvoice;
|
||||
@HiveField(19)
|
||||
final DateTime createdAt;
|
||||
@HiveField(20)
|
||||
final DateTime? updatedAt;
|
||||
@HiveField(21)
|
||||
final DateTime? lastReminderSent;
|
||||
|
||||
factory InvoiceModel.fromJson(Map<String, dynamic> json) => InvoiceModel(
|
||||
invoiceId: json['invoice_id'] as String,
|
||||
invoiceNumber: json['invoice_number'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
orderId: json['order_id'] as String?,
|
||||
invoiceType: InvoiceType.values.firstWhere((e) => e.name == json['invoice_type']),
|
||||
invoiceType: InvoiceType.values.firstWhere(
|
||||
(e) => e.name == json['invoice_type'],
|
||||
),
|
||||
issueDate: DateTime.parse(json['issue_date']?.toString() ?? ''),
|
||||
dueDate: DateTime.parse(json['due_date']?.toString() ?? ''),
|
||||
currency: json['currency'] as String? ?? 'VND',
|
||||
@@ -52,8 +99,12 @@ class InvoiceModel extends HiveObject {
|
||||
notes: json['notes'] as String?,
|
||||
erpnextInvoice: json['erpnext_invoice'] as String?,
|
||||
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||
updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']?.toString() ?? '') : null,
|
||||
lastReminderSent: json['last_reminder_sent'] != null ? DateTime.parse(json['last_reminder_sent']?.toString() ?? '') : null,
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at']?.toString() ?? '')
|
||||
: null,
|
||||
lastReminderSent: json['last_reminder_sent'] != null
|
||||
? DateTime.parse(json['last_reminder_sent']?.toString() ?? '')
|
||||
: null,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -81,6 +132,7 @@ class InvoiceModel extends HiveObject {
|
||||
'last_reminder_sent': lastReminderSent?.toIso8601String(),
|
||||
};
|
||||
|
||||
bool get isOverdue => DateTime.now().isAfter(dueDate) && status != InvoiceStatus.paid;
|
||||
bool get isOverdue =>
|
||||
DateTime.now().isAfter(dueDate) && status != InvoiceStatus.paid;
|
||||
bool get isPaid => status == InvoiceStatus.paid;
|
||||
}
|
||||
|
||||
@@ -5,16 +5,33 @@ part 'order_item_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.orderItemModel)
|
||||
class OrderItemModel extends HiveObject {
|
||||
OrderItemModel({required this.orderItemId, required this.orderId, required this.productId, required this.quantity, required this.unitPrice, required this.discountPercent, required this.subtotal, this.notes});
|
||||
|
||||
@HiveField(0) final String orderItemId;
|
||||
@HiveField(1) final String orderId;
|
||||
@HiveField(2) final String productId;
|
||||
@HiveField(3) final double quantity;
|
||||
@HiveField(4) final double unitPrice;
|
||||
@HiveField(5) final double discountPercent;
|
||||
@HiveField(6) final double subtotal;
|
||||
@HiveField(7) final String? notes;
|
||||
OrderItemModel({
|
||||
required this.orderItemId,
|
||||
required this.orderId,
|
||||
required this.productId,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
required this.discountPercent,
|
||||
required this.subtotal,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
@HiveField(0)
|
||||
final String orderItemId;
|
||||
@HiveField(1)
|
||||
final String orderId;
|
||||
@HiveField(2)
|
||||
final String productId;
|
||||
@HiveField(3)
|
||||
final double quantity;
|
||||
@HiveField(4)
|
||||
final double unitPrice;
|
||||
@HiveField(5)
|
||||
final double discountPercent;
|
||||
@HiveField(6)
|
||||
final double subtotal;
|
||||
@HiveField(7)
|
||||
final String? notes;
|
||||
|
||||
factory OrderItemModel.fromJson(Map<String, dynamic> json) => OrderItemModel(
|
||||
orderItemId: json['order_item_id'] as String,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user