add auth, format

This commit is contained in:
Phuoc Nguyen
2025-11-07 11:52:06 +07:00
parent 24a8508fce
commit 3803bd26e0
173 changed files with 8505 additions and 7116 deletions

View File

@@ -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 {
);
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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)

View File

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

View File

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

View File

@@ -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.');
}
// ============================================================================

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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);

View File

@@ -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,
),

View File

@@ -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

View File

@@ -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++) {

View File

@@ -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) {

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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,
),
],

View File

@@ -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(),

View File

@@ -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,
),
],

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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(

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

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

File diff suppressed because it is too large Load Diff

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

View File

@@ -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

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

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

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

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

View File

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

View File

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

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

View File

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

View File

@@ -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ế';
}
}

View File

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

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

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

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

View File

@@ -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

View File

@@ -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),
),
),
),

View File

@@ -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),

View File

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

View File

@@ -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,

View File

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

View File

@@ -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,
),
],
),
),

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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;

View File

@@ -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]}.')}';
}
}

View File

@@ -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,
),
),
],
),

View File

@@ -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) {

View File

@@ -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?,
);

View File

@@ -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() => {

View File

@@ -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 =>

View File

@@ -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),
),

View File

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

View File

@@ -62,11 +62,6 @@ class Favorite {
@override
int get hashCode {
return Object.hash(
favoriteId,
productId,
userId,
createdAt,
);
return Object.hash(favoriteId, productId, userId, createdAt);
}
}

View File

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

View File

@@ -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();
}
// ============================================================================

View File

@@ -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,

View File

@@ -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

View File

@@ -15,7 +15,7 @@ enum PromotionStatus {
upcoming,
/// Expired promotion
expired;
expired,
}
/// Promotion Entity

View File

@@ -222,7 +222,6 @@ class HomePage extends ConsumerWidget {
),
],
),
);
}

View File

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

View File

@@ -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,

View File

@@ -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(

View File

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

View File

@@ -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,

View File

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

View File

@@ -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,

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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,
),
],

View File

@@ -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,

View File

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

View File

@@ -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(

View File

@@ -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);

View File

@@ -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();

View File

@@ -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,
);

View File

@@ -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

View File

@@ -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

View File

@@ -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');

View File

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

View File

@@ -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