From 0708ed7d6fbd64898edc8ecc5c3ac2383ccf93d3 Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Thu, 20 Nov 2025 10:12:24 +0700 Subject: [PATCH] update info --- docs/user.sh | 6 + .../user_info_remote_datasource.dart | 131 ++++ .../account/data/models/user_info_model.dart | 280 ++++++++ .../user_info_repository_impl.dart | 101 +++ .../account/domain/entities/user_info.dart | 235 +++++++ .../repositories/user_info_repository.dart | 35 + .../domain/usecases/get_user_info.dart | 62 ++ .../presentation/pages/account_page.dart | 250 +++++-- .../presentation/pages/profile_edit_page.dart | 299 +++++--- .../providers/user_info_provider.dart | 200 ++++++ .../providers/user_info_provider.g.dart | 660 ++++++++++++++++++ .../cart/presentation/pages/cart_page.dart | 32 +- .../providers/cart_data_providers.dart | 0 .../providers/cart_data_providers.g.dart | 0 .../presentation/providers/cart_provider.dart | 2 +- .../widgets/cart_item_widget.dart | 10 +- pubspec.yaml | 2 +- 17 files changed, 2144 insertions(+), 161 deletions(-) create mode 100644 docs/user.sh create mode 100644 lib/features/account/data/datasources/user_info_remote_datasource.dart create mode 100644 lib/features/account/data/models/user_info_model.dart create mode 100644 lib/features/account/data/repositories/user_info_repository_impl.dart create mode 100644 lib/features/account/domain/entities/user_info.dart create mode 100644 lib/features/account/domain/repositories/user_info_repository.dart create mode 100644 lib/features/account/domain/usecases/get_user_info.dart create mode 100644 lib/features/account/presentation/providers/user_info_provider.dart create mode 100644 lib/features/account/presentation/providers/user_info_provider.g.dart rename lib/features/cart/{data => presentation}/providers/cart_data_providers.dart (100%) rename lib/features/cart/{data => presentation}/providers/cart_data_providers.g.dart (100%) diff --git a/docs/user.sh b/docs/user.sh new file mode 100644 index 0000000..af94437 --- /dev/null +++ b/docs/user.sh @@ -0,0 +1,6 @@ +#get user info +curl --location --request POST 'https://land.dbiz.com//api/method/building_material.building_material.api.user.get_user_info' \ +--header 'Cookie: sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; full_name=phuoc; sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; system_user=no; user_id=vodanh.2901%40gmail.com; user_image=https%3A//secure.gravatar.com/avatar/753a0e2601b9bd87aed417e2ad123bf8%3Fd%3D404%26s%3D200' \ +--header 'X-Frappe-Csrf-Token: a22fa53eeaa923f71f2fd879d2863a0985a6f2107f5f7f66d34cd62d' \ +--data '' + diff --git a/lib/features/account/data/datasources/user_info_remote_datasource.dart b/lib/features/account/data/datasources/user_info_remote_datasource.dart new file mode 100644 index 0000000..53deaf4 --- /dev/null +++ b/lib/features/account/data/datasources/user_info_remote_datasource.dart @@ -0,0 +1,131 @@ +/// User Info Remote Data Source +/// +/// Handles API calls for fetching user information. +library; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:worker/core/errors/exceptions.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/features/account/data/models/user_info_model.dart'; + +/// User Info Remote Data Source +/// +/// Provides methods for: +/// - Fetching current user information from API +/// - Uses existing Frappe authentication (cookies/tokens) +class UserInfoRemoteDataSource { + UserInfoRemoteDataSource(this._dioClient); + + final DioClient _dioClient; + + /// Get User Info + /// + /// Fetches the current authenticated user's information. + /// Uses existing Frappe session cookies/tokens for authentication. + /// + /// API: POST https://land.dbiz.com/api/method/building_material.building_material.api.user.get_user_info + /// Request: Empty POST (no body required) + /// + /// Response structure: + /// ```json + /// { + /// "message": { + /// "user_id": "...", + /// "full_name": "...", + /// "email": "...", + /// "phone_number": "...", + /// "role": "customer", + /// "status": "active", + /// "loyalty_tier": "gold", + /// "total_points": 1000, + /// "available_points": 800, + /// "expiring_points": 200, + /// // ... other fields + /// } + /// } + /// ``` + /// + /// Throws: + /// - [UnauthorizedException] if user not authenticated (401) + /// - [NotFoundException] if endpoint not found (404) + /// - [ServerException] if server error occurs (500+) + /// - [NetworkException] for other network errors + Future getUserInfo() async { + try { + debugPrint('🔵 [UserInfoDataSource] Fetching user info...'); + final startTime = DateTime.now(); + + // Make POST request with empty body + // Authentication is handled by auth interceptor (uses existing session) + final response = await _dioClient.post>( + '/api/method/building_material.building_material.api.user.get_user_info', + data: const {}, // Empty body as per API spec + ); + + final duration = DateTime.now().difference(startTime); + debugPrint('🟢 [UserInfoDataSource] Response received in ${duration.inMilliseconds}ms'); + debugPrint('🟢 [UserInfoDataSource] Status: ${response.statusCode}'); + debugPrint('🟢 [UserInfoDataSource] Data: ${response.data}'); + + // Check response status and data + if (response.statusCode == 200 && response.data != null) { + // Parse response to model + final model = UserInfoModel.fromJson(response.data!); + debugPrint('✅ [UserInfoDataSource] Successfully parsed user: ${model.fullName}'); + return model; + } else { + throw ServerException( + 'Failed to get user info: ${response.statusCode}', + response.statusCode, + ); + } + } on DioException catch (e) { + // Handle specific HTTP status codes + if (e.response?.statusCode == 401) { + throw const UnauthorizedException( + 'Session expired. Please login again.', + ); + } else if (e.response?.statusCode == 403) { + throw const ForbiddenException(); + } else if (e.response?.statusCode == 404) { + throw NotFoundException('User info endpoint not found'); + } else if (e.response?.statusCode != null && + e.response!.statusCode! >= 500) { + throw ServerException( + 'Server error: ${e.response?.statusMessage ?? "Unknown error"}', + e.response?.statusCode, + ); + } else if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout) { + throw const TimeoutException(); + } else if (e.type == DioExceptionType.connectionError) { + throw const NoInternetException(); + } else { + throw NetworkException( + e.message ?? 'Failed to get user info', + statusCode: e.response?.statusCode, + ); + } + } catch (e) { + // Handle unexpected errors + if (e is ServerException || + e is UnauthorizedException || + e is NetworkException || + e is NotFoundException) { + rethrow; + } + throw ServerException('Unexpected error: $e'); + } + } + + /// Refresh User Info + /// + /// Same as getUserInfo but with force refresh parameter. + /// Useful when you want to bypass cache and get fresh data. + Future refreshUserInfo() async { + // For now, same as getUserInfo + // Could add cache-busting headers in the future if needed + return getUserInfo(); + } +} diff --git a/lib/features/account/data/models/user_info_model.dart b/lib/features/account/data/models/user_info_model.dart new file mode 100644 index 0000000..34538b4 --- /dev/null +++ b/lib/features/account/data/models/user_info_model.dart @@ -0,0 +1,280 @@ +/// User Info Model +/// +/// Data model for user information from API. +/// Handles JSON serialization and conversion to domain entity. +library; + +import 'package:worker/core/database/models/enums.dart'; +import 'package:worker/features/account/domain/entities/user_info.dart'; + +/// User Info Model +/// +/// Maps API response from get_user_info endpoint to UserInfo entity. +class UserInfoModel { + const UserInfoModel({ + required this.userId, + required this.fullName, + this.email, + this.phoneNumber, + required this.role, + required this.status, + required this.loyaltyTier, + this.totalPoints = 0, + this.availablePoints = 0, + this.expiringPoints = 0, + this.avatarUrl, + this.companyName, + this.taxId, + this.address, + this.cccd, + this.referralCode, + this.erpnextCustomerId, + this.createdAt, + this.updatedAt, + }); + + final String userId; + final String fullName; + final String? email; + final String? phoneNumber; + final UserRole role; + final UserStatus status; + final LoyaltyTier loyaltyTier; + final int totalPoints; + final int availablePoints; + final int expiringPoints; + final String? avatarUrl; + final String? companyName; + final String? taxId; + final String? address; + final String? cccd; + final String? referralCode; + final String? erpnextCustomerId; + final DateTime? createdAt; + final DateTime? updatedAt; + + // ========================================================================= + // JSON SERIALIZATION + // ========================================================================= + + /// Create UserInfoModel from API JSON response + /// + /// Expected API response structure: + /// ```json + /// { + /// "message": { + /// "user_id": "...", + /// "full_name": "...", + /// "email": "...", + /// "phone_number": "...", + /// "role": "customer", + /// "status": "active", + /// "loyalty_tier": "gold", + /// "total_points": 1000, + /// "available_points": 800, + /// "expiring_points": 200, + /// "avatar_url": "...", + /// "company_name": "...", + /// "tax_id": "...", + /// "address": "...", + /// "cccd": "...", + /// "referral_code": "...", + /// "erpnext_customer_id": "...", + /// "created_at": "...", + /// "updated_at": "...", + /// "last_login_at": "..." + /// } + /// } + /// ``` + factory UserInfoModel.fromJson(Map json) { + // API response structure: { "message": { "success": true, "data": {...} } } + final message = json['message'] as Map?; + final data = message?['data'] as Map? ?? json; + + return UserInfoModel( + // Use email as userId since API doesn't provide user_id + userId: data['email'] as String? ?? data['phone'] as String? ?? 'unknown', + fullName: data['full_name'] as String? ?? '', + email: data['email'] as String?, + phoneNumber: data['phone'] as String?, + // Default values for fields not in this API + role: UserRole.customer, // Default to customer + status: UserStatus.active, // Default to active + loyaltyTier: LoyaltyTier.bronze, // Default to bronze + totalPoints: 0, + availablePoints: 0, + expiringPoints: 0, + avatarUrl: data['avatar'] as String?, + companyName: data['company_name'] as String?, + taxId: data['tax_code'] as String?, + address: data['address'] as String?, + cccd: data['id_card_front'] as String?, // Store front ID card + referralCode: null, + erpnextCustomerId: null, + createdAt: _parseDateTime(data['date_of_birth'] as String?), + updatedAt: _parseDateTime(data['date_of_birth'] as String?), + ); + } + + /// Convert UserInfoModel to JSON + Map toJson() { + return { + 'user_id': userId, + 'full_name': fullName, + 'email': email, + 'phone_number': phoneNumber, + 'role': role.name, + 'status': status.name, + 'loyalty_tier': loyaltyTier.name, + 'total_points': totalPoints, + 'available_points': availablePoints, + 'expiring_points': expiringPoints, + 'avatar_url': avatarUrl, + 'company_name': companyName, + 'tax_id': taxId, + 'address': address, + 'cccd': cccd, + 'referral_code': referralCode, + 'erpnext_customer_id': erpnextCustomerId, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + // ========================================================================= + // DOMAIN CONVERSION + // ========================================================================= + + /// Convert to domain entity + UserInfo toEntity() { + final now = DateTime.now(); + return UserInfo( + userId: userId, + fullName: fullName, + email: email, + phoneNumber: phoneNumber, + role: role, + status: status, + loyaltyTier: loyaltyTier, + totalPoints: totalPoints, + availablePoints: availablePoints, + expiringPoints: expiringPoints, + avatarUrl: avatarUrl, + companyName: companyName, + taxId: taxId, + address: address, + cccd: cccd, + referralCode: referralCode, + erpnextCustomerId: erpnextCustomerId, + createdAt: createdAt ?? now, + updatedAt: updatedAt ?? now, + ); + } + + /// Create model from domain entity + factory UserInfoModel.fromEntity(UserInfo entity) { + return UserInfoModel( + userId: entity.userId, + fullName: entity.fullName, + email: entity.email, + phoneNumber: entity.phoneNumber, + role: entity.role, + status: entity.status, + loyaltyTier: entity.loyaltyTier, + totalPoints: entity.totalPoints, + availablePoints: entity.availablePoints, + expiringPoints: entity.expiringPoints, + avatarUrl: entity.avatarUrl, + companyName: entity.companyName, + taxId: entity.taxId, + address: entity.address, + cccd: entity.cccd, + referralCode: entity.referralCode, + erpnextCustomerId: entity.erpnextCustomerId, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + ); + } + + // ========================================================================= + // HELPER METHODS + // ========================================================================= + + /// Parse user role from string + static UserRole _parseUserRole(String? role) { + if (role == null) return UserRole.customer; + + return UserRole.values.firstWhere( + (e) => e.name.toLowerCase() == role.toLowerCase(), + orElse: () => UserRole.customer, + ); + } + + /// Parse user status from string + static UserStatus _parseUserStatus(String? status) { + if (status == null) return UserStatus.pending; + + // Handle ERPNext status values + final normalizedStatus = status.toLowerCase(); + if (normalizedStatus == 'enabled' || normalizedStatus == 'active') { + return UserStatus.active; + } + if (normalizedStatus == 'disabled' || normalizedStatus == 'inactive') { + return UserStatus.suspended; + } + + return UserStatus.values.firstWhere( + (e) => e.name.toLowerCase() == normalizedStatus, + orElse: () => UserStatus.pending, + ); + } + + /// Parse loyalty tier from string + static LoyaltyTier _parseLoyaltyTier(String? tier) { + if (tier == null) return LoyaltyTier.bronze; + + return LoyaltyTier.values.firstWhere( + (e) => e.name.toLowerCase() == tier.toLowerCase(), + orElse: () => LoyaltyTier.bronze, + ); + } + + /// Parse integer from dynamic value + static int _parseInt(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value) ?? 0; + return 0; + } + + /// Parse DateTime from string + static DateTime? _parseDateTime(String? dateString) { + if (dateString == null || dateString.isEmpty) return null; + try { + return DateTime.parse(dateString); + } catch (e) { + return null; + } + } + + // ========================================================================= + // EQUALITY + // ========================================================================= + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is UserInfoModel && other.userId == userId; + } + + @override + int get hashCode => userId.hashCode; + + @override + String toString() { + return 'UserInfoModel(userId: $userId, fullName: $fullName, ' + 'tier: $loyaltyTier, points: $totalPoints)'; + } +} diff --git a/lib/features/account/data/repositories/user_info_repository_impl.dart b/lib/features/account/data/repositories/user_info_repository_impl.dart new file mode 100644 index 0000000..ac95b27 --- /dev/null +++ b/lib/features/account/data/repositories/user_info_repository_impl.dart @@ -0,0 +1,101 @@ +/// User Info Repository Implementation +/// +/// Implements the UserInfoRepository interface with API integration. +library; + +import 'package:worker/core/errors/exceptions.dart'; +import 'package:worker/features/account/data/datasources/user_info_remote_datasource.dart'; +import 'package:worker/features/account/domain/entities/user_info.dart'; +import 'package:worker/features/account/domain/repositories/user_info_repository.dart'; + +/// User Info Repository Implementation +/// +/// Handles user information operations with: +/// - API data fetching via remote datasource +/// - Error handling and transformation +/// - Entity conversion +class UserInfoRepositoryImpl implements UserInfoRepository { + UserInfoRepositoryImpl({ + required this.remoteDataSource, + }); + + final UserInfoRemoteDataSource remoteDataSource; + + // ========================================================================= + // GET USER INFO + // ========================================================================= + + @override + Future getUserInfo() async { + try { + _debugPrint('Fetching user info from API'); + + // Fetch from remote datasource + final userInfoModel = await remoteDataSource.getUserInfo(); + + _debugPrint('Successfully fetched user info: ${userInfoModel.fullName}'); + + // Convert model to entity + return userInfoModel.toEntity(); + } on UnauthorizedException catch (e) { + _debugPrint('Unauthorized error: $e'); + rethrow; + } on NotFoundException catch (e) { + _debugPrint('Not found error: $e'); + rethrow; + } on ServerException catch (e) { + _debugPrint('Server error: $e'); + rethrow; + } on NetworkException catch (e) { + _debugPrint('Network error: $e'); + rethrow; + } catch (e) { + _debugPrint('Unexpected error: $e'); + throw ServerException('Failed to get user info: $e'); + } + } + + // ========================================================================= + // REFRESH USER INFO + // ========================================================================= + + @override + Future refreshUserInfo() async { + try { + _debugPrint('Refreshing user info from API'); + + // Fetch fresh data from remote datasource + final userInfoModel = await remoteDataSource.refreshUserInfo(); + + _debugPrint('Successfully refreshed user info: ${userInfoModel.fullName}'); + + // Convert model to entity + return userInfoModel.toEntity(); + } on UnauthorizedException catch (e) { + _debugPrint('Unauthorized error on refresh: $e'); + rethrow; + } on NotFoundException catch (e) { + _debugPrint('Not found error on refresh: $e'); + rethrow; + } on ServerException catch (e) { + _debugPrint('Server error on refresh: $e'); + rethrow; + } on NetworkException catch (e) { + _debugPrint('Network error on refresh: $e'); + rethrow; + } catch (e) { + _debugPrint('Unexpected error on refresh: $e'); + throw ServerException('Failed to refresh user info: $e'); + } + } +} + +// ============================================================================ +// DEBUG UTILITIES +// ============================================================================ + +/// Debug print helper +void _debugPrint(String message) { + // ignore: avoid_print + print('[UserInfoRepository] $message'); +} diff --git a/lib/features/account/domain/entities/user_info.dart b/lib/features/account/domain/entities/user_info.dart new file mode 100644 index 0000000..9938007 --- /dev/null +++ b/lib/features/account/domain/entities/user_info.dart @@ -0,0 +1,235 @@ +/// Domain Entity: UserInfo +/// +/// Represents complete user information fetched from the API. +/// This is a plain Dart class matching the app's entity pattern. +library; + +import 'package:worker/core/database/models/enums.dart'; + +/// UserInfo Entity +/// +/// Contains all user account information including: +/// - Personal details (name, email, phone, CCCD) +/// - Role and status +/// - Loyalty program data (tier, points) +/// - Company information +/// - ERPNext integration data +class UserInfo { + /// Unique user identifier (name field from ERPNext) + final String userId; + + /// Full name + final String fullName; + + /// Email address + final String? email; + + /// Phone number + final String? phoneNumber; + + /// User role + final UserRole role; + + /// Account status + final UserStatus status; + + /// Current loyalty tier + final LoyaltyTier loyaltyTier; + + /// Total loyalty points earned (lifetime) + final int totalPoints; + + /// Available points for redemption + final int availablePoints; + + /// Points expiring soon + final int expiringPoints; + + /// Avatar image URL + final String? avatarUrl; + + /// Company name + final String? companyName; + + /// Tax identification number + final String? taxId; + + /// Address + final String? address; + + /// CCCD/ID card number + final String? cccd; + + /// Referral code + final String? referralCode; + + /// ERPNext customer ID + final String? erpnextCustomerId; + + /// Account creation timestamp + final DateTime createdAt; + + /// Last update timestamp + final DateTime updatedAt; + + const UserInfo({ + required this.userId, + required this.fullName, + this.email, + this.phoneNumber, + required this.role, + required this.status, + required this.loyaltyTier, + required this.totalPoints, + required this.availablePoints, + required this.expiringPoints, + this.avatarUrl, + this.companyName, + this.taxId, + this.address, + this.cccd, + this.referralCode, + this.erpnextCustomerId, + required this.createdAt, + required this.updatedAt, + }); + + /// Check if user is active + bool get isActive => status == UserStatus.active; + + /// Check if user is pending approval + bool get isPending => status == UserStatus.pending; + + /// Check if user has company info + bool get hasCompanyInfo => + companyName != null && companyName!.isNotEmpty; + + /// Get user initials for avatar fallback + String get initials { + final nameParts = fullName.trim().split(' '); + if (nameParts.isEmpty) return '?'; + if (nameParts.length == 1) { + return nameParts[0].substring(0, 1).toUpperCase(); + } + // First letter of first name + first letter of last name + return '${nameParts.first.substring(0, 1)}${nameParts.last.substring(0, 1)}' + .toUpperCase(); + } + + /// Get tier display name + String get tierDisplayName { + switch (loyaltyTier) { + case LoyaltyTier.titan: + return 'Titan'; + case LoyaltyTier.diamond: + return 'Diamond'; + case LoyaltyTier.platinum: + return 'Platinum'; + case LoyaltyTier.gold: + return 'Gold'; + case LoyaltyTier.silver: + return 'Silver'; + case LoyaltyTier.bronze: + return 'Bronze'; + } + } + + /// Copy with method for immutability + UserInfo copyWith({ + String? userId, + String? fullName, + String? email, + String? phoneNumber, + UserRole? role, + UserStatus? status, + LoyaltyTier? loyaltyTier, + int? totalPoints, + int? availablePoints, + int? expiringPoints, + String? avatarUrl, + String? companyName, + String? taxId, + String? address, + String? cccd, + String? referralCode, + String? erpnextCustomerId, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return UserInfo( + userId: userId ?? this.userId, + fullName: fullName ?? this.fullName, + email: email ?? this.email, + phoneNumber: phoneNumber ?? this.phoneNumber, + role: role ?? this.role, + status: status ?? this.status, + loyaltyTier: loyaltyTier ?? this.loyaltyTier, + totalPoints: totalPoints ?? this.totalPoints, + availablePoints: availablePoints ?? this.availablePoints, + expiringPoints: expiringPoints ?? this.expiringPoints, + avatarUrl: avatarUrl ?? this.avatarUrl, + companyName: companyName ?? this.companyName, + taxId: taxId ?? this.taxId, + address: address ?? this.address, + cccd: cccd ?? this.cccd, + referralCode: referralCode ?? this.referralCode, + erpnextCustomerId: erpnextCustomerId ?? this.erpnextCustomerId, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is UserInfo && + other.userId == userId && + other.fullName == fullName && + other.email == email && + other.phoneNumber == phoneNumber && + other.role == role && + other.status == status && + other.loyaltyTier == loyaltyTier && + other.totalPoints == totalPoints && + other.availablePoints == availablePoints && + other.expiringPoints == expiringPoints && + other.avatarUrl == avatarUrl && + other.companyName == companyName && + other.taxId == taxId && + other.address == address && + other.cccd == cccd && + other.referralCode == referralCode && + other.erpnextCustomerId == erpnextCustomerId; + } + + @override + int get hashCode { + return Object.hash( + userId, + fullName, + email, + phoneNumber, + role, + status, + loyaltyTier, + totalPoints, + availablePoints, + expiringPoints, + avatarUrl, + companyName, + taxId, + address, + cccd, + referralCode, + erpnextCustomerId, + ); + } + + @override + String toString() { + return 'UserInfo(userId: $userId, fullName: $fullName, ' + 'role: $role, status: $status, loyaltyTier: $loyaltyTier, ' + 'totalPoints: $totalPoints, availablePoints: $availablePoints)'; + } +} diff --git a/lib/features/account/domain/repositories/user_info_repository.dart b/lib/features/account/domain/repositories/user_info_repository.dart new file mode 100644 index 0000000..d403ebb --- /dev/null +++ b/lib/features/account/domain/repositories/user_info_repository.dart @@ -0,0 +1,35 @@ +/// User Info Repository Interface +/// +/// Defines the contract for user information data operations. +library; + +import 'package:worker/features/account/domain/entities/user_info.dart'; + +/// User Info Repository +/// +/// Repository interface for user information operations. +/// Implementations should handle: +/// - Fetching user info from API +/// - Error handling and retries +/// - Optional local caching +abstract class UserInfoRepository { + /// Get current user information + /// + /// Fetches the authenticated user's information from the API. + /// + /// Returns [UserInfo] entity with user data. + /// + /// Throws: + /// - [UnauthorizedException] if session expired + /// - [NetworkException] if network error occurs + /// - [ServerException] if server error occurs + Future getUserInfo(); + + /// Refresh user information + /// + /// Forces a refresh from the server, bypassing any cache. + /// Useful after profile updates or when fresh data is needed. + /// + /// Returns [UserInfo] entity with fresh user data. + Future refreshUserInfo(); +} diff --git a/lib/features/account/domain/usecases/get_user_info.dart b/lib/features/account/domain/usecases/get_user_info.dart new file mode 100644 index 0000000..cd4d0c1 --- /dev/null +++ b/lib/features/account/domain/usecases/get_user_info.dart @@ -0,0 +1,62 @@ +/// Use Case: Get User Info +/// +/// Retrieves the current authenticated user's information. +/// This use case encapsulates the business logic for fetching user info. +library; + +import 'package:worker/features/account/domain/entities/user_info.dart'; +import 'package:worker/features/account/domain/repositories/user_info_repository.dart'; + +/// Get User Info Use Case +/// +/// Fetches the current authenticated user's information from the API. +/// +/// Usage: +/// ```dart +/// final getUserInfo = GetUserInfo(repository); +/// final userInfo = await getUserInfo(); +/// ``` +/// +/// This use case: +/// - Retrieves user info from repository +/// - Can add business logic if needed (e.g., validation, analytics) +/// - Returns UserInfo entity +class GetUserInfo { + /// User info repository instance + final UserInfoRepository repository; + + /// Constructor + const GetUserInfo(this.repository); + + /// Execute the use case + /// + /// Returns [UserInfo] with user's account information. + /// + /// Throws: + /// - [UnauthorizedException] if user not authenticated + /// - [NetworkException] if network error occurs + /// - [ServerException] if server error occurs + Future call() async { + // TODO: Add business logic here if needed + // For example: + // - Log analytics event + // - Validate user session + // - Transform data if needed + // - Check feature flags + + return await repository.getUserInfo(); + } + + /// Execute with force refresh + /// + /// Forces a refresh from the server instead of using cached data. + /// + /// Use this when: + /// - User explicitly pulls to refresh + /// - After profile updates + /// - After points redemption + /// - When fresh data is critical + Future refresh() async { + return await repository.refreshUserInfo(); + } +} diff --git a/lib/features/account/presentation/pages/account_page.dart b/lib/features/account/presentation/pages/account_page.dart index c7567da..89da4bc 100644 --- a/lib/features/account/presentation/pages/account_page.dart +++ b/lib/features/account/presentation/pages/account_page.dart @@ -8,14 +8,20 @@ /// - Logout button library; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.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/database/hive_initializer.dart'; +import 'package:worker/core/database/models/enums.dart'; import 'package:worker/core/router/app_router.dart'; import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/account/domain/entities/user_info.dart' + as domain; +import 'package:worker/features/account/presentation/providers/user_info_provider.dart' + hide UserInfo; import 'package:worker/features/account/presentation/widgets/account_menu_item.dart'; import 'package:worker/features/auth/presentation/providers/auth_provider.dart'; @@ -27,30 +33,98 @@ class AccountPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final userInfoAsync = ref.watch(userInfoProvider); + return Scaffold( backgroundColor: const Color(0xFFF4F6F8), body: SafeArea( - child: SingleChildScrollView( - child: Column( - spacing: AppSpacing.md, - children: [ - // Simple Header - _buildHeader(), + child: userInfoAsync.when( + loading: () => const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: AppColors.primaryBlue), + SizedBox(height: AppSpacing.md), + Text( + 'Đang tải thông tin...', + style: TextStyle( + fontSize: 14, + color: AppColors.grey500, + ), + ), + ], + ), + ), + error: (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FaIcon( + FontAwesomeIcons.circleExclamation, + size: 64, + color: AppColors.danger, + ), + const SizedBox(height: AppSpacing.lg), + const Text( + 'Không thể tải thông tin tài khoản', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.md), + Text( + error.toString(), + style: const TextStyle( + fontSize: 14, + color: AppColors.grey500, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + ElevatedButton.icon( + onPressed: () => + ref.read(userInfoProvider.notifier).refresh(), + icon: + const FaIcon(FontAwesomeIcons.arrowsRotate, size: 16), + label: const Text('Thử lại'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: AppColors.white, + ), + ), + ], + ), + ), + data: (userInfo) => RefreshIndicator( + onRefresh: () async { + await ref.read(userInfoProvider.notifier).refresh(); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + spacing: AppSpacing.md, + children: [ + // Simple Header + _buildHeader(), - // User Profile Card - _buildProfileCard(context), + // User Profile Card with API data + _buildProfileCard(context, userInfo), - // Account Menu Section - _buildAccountMenu(context), + // Account Menu Section + _buildAccountMenu(context), - // Support Section - _buildSupportSection(context), + // Support Section + _buildSupportSection(context), - // Logout Button - _buildLogoutButton(context, ref), + // Logout Button + _buildLogoutButton(context, ref), - const SizedBox(height: AppSpacing.lg), - ], + const SizedBox(height: AppSpacing.lg), + ], + ), + ), ), ), ), @@ -84,7 +158,10 @@ class AccountPage extends ConsumerWidget { } /// Build user profile card with avatar and info - Widget _buildProfileCard(BuildContext context) { + Widget _buildProfileCard( + BuildContext context, + domain.UserInfo userInfo, + ) { return Container( margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md), padding: const EdgeInsets.all(AppSpacing.md), @@ -101,54 +178,109 @@ class AccountPage extends ConsumerWidget { ), child: Row( children: [ - // Avatar with gradient background - Container( - width: 80, - height: 80, - decoration: const BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [Color(0xFF005B9A), Color(0xFF38B6FF)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: const Center( - child: Text( - 'LQ', - style: TextStyle( - color: Colors.white, - fontSize: 32, - fontWeight: FontWeight.w700, + // Avatar with API data or gradient fallback + userInfo.avatarUrl != null + ? ClipOval( + child: CachedNetworkImage( + imageUrl: userInfo.avatarUrl!, + width: 80, + height: 80, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + width: 80, + height: 80, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [Color(0xFF005B9A), Color(0xFF38B6FF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: const Center( + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ), + ), + errorWidget: (context, url, error) => Container( + width: 80, + height: 80, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [Color(0xFF005B9A), Color(0xFF38B6FF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Center( + child: Text( + userInfo.initials, + style: const TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ) + : Container( + width: 80, + height: 80, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [Color(0xFF005B9A), Color(0xFF38B6FF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Center( + child: Text( + userInfo.initials, + style: const TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.w700, + ), + ), + ), ), - ), - ), - ), const SizedBox(width: AppSpacing.md), - // User info + // User info from API Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.xs, children: [ - const Text( - 'La Nguyen Quynh', - style: TextStyle( + Text( + userInfo.fullName, + style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.grey900, ), ), - const SizedBox(height: 4), - const Text( - 'Kiến trúc sư · Hạng Diamond', - style: TextStyle(fontSize: 13, color: AppColors.grey500), - ), - const SizedBox(height: 4), - const Text( - '0983 441 099', - style: TextStyle(fontSize: 13, color: AppColors.primaryBlue), + Text( + '${_getRoleDisplayName(userInfo.role)} · Hạng ${userInfo.tierDisplayName}', + style: const TextStyle( + fontSize: 13, + color: AppColors.grey500, + ), ), + if (userInfo.phoneNumber != null) + Text( + userInfo.phoneNumber!, + style: const TextStyle( + fontSize: 13, + color: AppColors.primaryBlue, + ), + ), ], ), ), @@ -472,4 +604,18 @@ class AccountPage extends ConsumerWidget { } } } + + /// Get Vietnamese display name for user role + String _getRoleDisplayName(UserRole role) { + switch (role) { + case UserRole.customer: + return 'Khách hàng'; + case UserRole.distributor: + return 'Đại lý phân phối'; + case UserRole.admin: + return 'Quản trị viên'; + case UserRole.staff: + return 'Nhân viên'; + } + } } diff --git a/lib/features/account/presentation/pages/profile_edit_page.dart b/lib/features/account/presentation/pages/profile_edit_page.dart index 239f421..ec5a491 100644 --- a/lib/features/account/presentation/pages/profile_edit_page.dart +++ b/lib/features/account/presentation/pages/profile_edit_page.dart @@ -18,6 +18,7 @@ import 'package:hooks_riverpod/hooks_riverpod.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/features/account/presentation/providers/user_info_provider.dart' hide UserInfo; /// Profile Edit Page /// @@ -27,47 +28,128 @@ class ProfileEditPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // Watch user info from API + final userInfoAsync = ref.watch(userInfoProvider); + // Form key for validation final formKey = useMemoized(() => GlobalKey()); // Image picker final selectedImage = useState(null); - // Form controllers - final nameController = useTextEditingController(text: 'Hoàng Minh Hiệp'); - final phoneController = useTextEditingController(text: '0347302911'); - final emailController = useTextEditingController( - text: 'hoanghiep@example.com', - ); - final birthDateController = useTextEditingController(text: '15/03/1985'); - final idNumberController = useTextEditingController(text: '123456789012'); - final taxIdController = useTextEditingController(text: '0359837618'); - final companyController = useTextEditingController( - text: 'Công ty TNHH Xây dựng ABC', - ); - final addressController = useTextEditingController( - text: '123 Man Thiện, Thủ Đức, Hồ Chí Minh', - ); - final experienceController = useTextEditingController(text: '10'); - - // Dropdown values - final selectedGender = useState('male'); - final selectedPosition = useState('contractor'); - // Has unsaved changes final hasChanges = useState(false); - return PopScope( - canPop: !hasChanges.value, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) return; + return userInfoAsync.when( + loading: () => Scaffold( + backgroundColor: const Color(0xFFF4F6F8), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: const Text( + 'Thông tin cá nhân', + style: TextStyle( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: false, + ), + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: AppColors.primaryBlue), + SizedBox(height: AppSpacing.md), + Text( + 'Đang tải thông tin...', + style: TextStyle( + fontSize: 14, + color: AppColors.grey500, + ), + ), + ], + ), + ), + ), + error: (error, stack) => Scaffold( + backgroundColor: const Color(0xFFF4F6F8), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20), + onPressed: () => context.pop(), + ), + title: const Text( + 'Thông tin cá nhân', + style: TextStyle( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: false, + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FaIcon( + FontAwesomeIcons.circleExclamation, + size: 64, + color: AppColors.danger, + ), + const SizedBox(height: AppSpacing.lg), + const Text( + 'Không thể tải thông tin người dùng', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.md), + ElevatedButton.icon( + onPressed: () => ref.read(userInfoProvider.notifier).refresh(), + icon: const FaIcon(FontAwesomeIcons.arrowsRotate, size: 16), + label: const Text('Thử lại'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: AppColors.white, + ), + ), + ], + ), + ), + ), + data: (userInfo) { + // Form controllers populated with user data + final nameController = useTextEditingController(text: userInfo.fullName); + final phoneController = useTextEditingController(text: userInfo.phoneNumber ?? ''); + final emailController = useTextEditingController(text: userInfo.email ?? ''); + final birthDateController = useTextEditingController(text: ''); // TODO: Add birthdate to API + final idNumberController = useTextEditingController(text: userInfo.cccd ?? ''); + final taxIdController = useTextEditingController(text: userInfo.taxId ?? ''); + final companyController = useTextEditingController(text: userInfo.companyName ?? ''); + final addressController = useTextEditingController(text: userInfo.address ?? ''); + final experienceController = useTextEditingController(text: ''); // TODO: Add experience to API - final shouldPop = await _showUnsavedChangesDialog(context); - if (shouldPop == true && context.mounted) { - Navigator.of(context).pop(); - } - }, - child: Scaffold( + // Dropdown values + final selectedGender = useState('male'); // TODO: Add gender to API + final selectedPosition = useState('contractor'); // TODO: Map from userInfo.role + + return PopScope( + canPop: !hasChanges.value, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + + final shouldPop = await _showUnsavedChangesDialog(context); + if (shouldPop == true && context.mounted) { + Navigator.of(context).pop(); + } + }, + child: Scaffold( backgroundColor: const Color(0xFFF4F6F8), appBar: AppBar( backgroundColor: Colors.white, @@ -107,7 +189,12 @@ class ProfileEditPage extends HookConsumerWidget { const SizedBox(height: AppSpacing.md), // Profile Avatar Section - _buildAvatarSection(context, selectedImage), + _buildAvatarSection( + context, + selectedImage, + userInfo.initials, + userInfo.avatarUrl, + ), const SizedBox(height: AppSpacing.md), @@ -276,7 +363,9 @@ class ProfileEditPage extends HookConsumerWidget { ), ), ), - ), + ), + ); + }, ); } @@ -284,70 +373,83 @@ class ProfileEditPage extends HookConsumerWidget { Widget _buildAvatarSection( BuildContext context, ValueNotifier selectedImage, + String initials, + String? avatarUrl, ) { - return Center( - child: Stack( - children: [ - // Avatar - Container( - width: 100, - height: 100, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColors.primaryBlue, - image: selectedImage.value != null - ? DecorationImage( - image: FileImage(selectedImage.value!), - fit: BoxFit.cover, + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Center( + child: Stack( + clipBehavior: Clip.none, + children: [ + // Avatar + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.primaryBlue, + image: selectedImage.value != null + ? DecorationImage( + image: FileImage(selectedImage.value!), + fit: BoxFit.cover, + ) + : avatarUrl != null + ? DecorationImage( + image: NetworkImage(avatarUrl), + fit: BoxFit.cover, + ) + : null, + ), + child: selectedImage.value == null && avatarUrl == null + ? Center( + child: Text( + initials, + style: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), ) : null, ), - child: selectedImage.value == null - ? const Center( - child: Text( - 'HMH', - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ) - : null, - ), - // Edit Button - Positioned( - bottom: 0, - right: 0, - child: GestureDetector( - onTap: () async { - await _pickImage(context, selectedImage); - }, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: AppColors.primaryBlue, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 4, - offset: const Offset(0, 2), + // Edit Button + Positioned( + bottom: 0, + right: 0, + child: GestureDetector( + onTap: () async { + await _pickImage(context, selectedImage); + }, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.primaryBlue, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: const Center( + child: FaIcon( + FontAwesomeIcons.camera, + size: 16, + color: Colors.white, ), - ], - ), - child: const FaIcon( - FontAwesomeIcons.camera, - size: 14, - color: Colors.white, + ), ), ), ), - ), - ], + ], + ), ), ); } @@ -473,7 +575,18 @@ class ProfileEditPage extends HookConsumerWidget { horizontal: 16, vertical: 12, ), - suffixIcon: const FaIcon(FontAwesomeIcons.calendar, size: 18), + suffixIcon: const Padding( + padding: EdgeInsets.only(right: 12), + child: FaIcon( + FontAwesomeIcons.calendar, + size: 20, + color: AppColors.grey500, + ), + ), + suffixIconConstraints: const BoxConstraints( + minWidth: 48, + minHeight: 48, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.input), borderSide: const BorderSide(color: Color(0xFFE2E8F0)), @@ -517,6 +630,14 @@ class ProfileEditPage extends HookConsumerWidget { DropdownButtonFormField( initialValue: value, onChanged: onChanged, + icon: const Padding( + padding: EdgeInsets.only(right: 12), + child: FaIcon( + FontAwesomeIcons.chevronDown, + size: 16, + color: AppColors.grey500, + ), + ), decoration: InputDecoration( filled: true, fillColor: const Color(0xFFF8FAFC), diff --git a/lib/features/account/presentation/providers/user_info_provider.dart b/lib/features/account/presentation/providers/user_info_provider.dart new file mode 100644 index 0000000..a0a2cf8 --- /dev/null +++ b/lib/features/account/presentation/providers/user_info_provider.dart @@ -0,0 +1,200 @@ +/// Provider: User Info Provider +/// +/// Manages the state of user information using Riverpod. +/// Fetches data from API and provides it to the UI. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/features/account/data/datasources/user_info_remote_datasource.dart'; +import 'package:worker/features/account/data/repositories/user_info_repository_impl.dart'; +import 'package:worker/features/account/domain/entities/user_info.dart' + as domain; +import 'package:worker/features/account/domain/repositories/user_info_repository.dart'; +import 'package:worker/features/account/domain/usecases/get_user_info.dart'; + +part 'user_info_provider.g.dart'; + +// ============================================================================ +// DATA SOURCE PROVIDERS +// ============================================================================ + +/// User Info Remote Data Source Provider +@riverpod +Future userInfoRemoteDataSource(Ref ref) async { + final dioClient = await ref.watch(dioClientProvider.future); + return UserInfoRemoteDataSource(dioClient); +} + +// ============================================================================ +// REPOSITORY PROVIDERS +// ============================================================================ + +/// User Info Repository Provider +@riverpod +Future userInfoRepository(Ref ref) async { + final remoteDataSource = await ref.watch( + userInfoRemoteDataSourceProvider.future, + ); + return UserInfoRepositoryImpl(remoteDataSource: remoteDataSource); +} + +// ============================================================================ +// USE CASE PROVIDERS +// ============================================================================ + +/// Get User Info Use Case Provider +@riverpod +Future getUserInfoUseCase(Ref ref) async { + final repository = await ref.watch(userInfoRepositoryProvider.future); + return GetUserInfo(repository); +} + +// ============================================================================ +// STATE PROVIDERS +// ============================================================================ + +/// User Info Provider +/// +/// Fetches and manages user information state. +/// Automatically loads user info on initialization. +/// Provides refresh functionality for manual updates. +/// +/// Usage: +/// ```dart +/// // In a widget +/// final userInfoAsync = ref.watch(userInfoProvider); +/// +/// userInfoAsync.when( +/// data: (userInfo) => Text(userInfo.fullName), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// +/// // To refresh +/// ref.read(userInfoProvider.notifier).refresh(); +/// ``` +@riverpod +class UserInfo extends _$UserInfo { + @override + Future build() async { + // Fetch user info on initialization + final useCase = await ref.watch(getUserInfoUseCaseProvider.future); + return await useCase(); + } + + /// Refresh user information + /// + /// Forces a fresh fetch from the API. + /// Updates the state with new data. + /// + /// Usage: + /// ```dart + /// await ref.read(userInfoProvider.notifier).refresh(); + /// ``` + Future refresh() async { + // Set loading state + state = const AsyncValue.loading(); + + // Fetch fresh data + state = await AsyncValue.guard(() async { + final useCase = await ref.read(getUserInfoUseCaseProvider.future); + return await useCase.refresh(); + }); + } + + /// Update user info locally + /// + /// Updates the cached state without fetching from API. + /// Useful after local profile updates. + /// + /// Usage: + /// ```dart + /// ref.read(userInfoProvider.notifier).updateLocal(updatedUserInfo); + /// ``` + void updateLocal(domain.UserInfo updatedInfo) { + state = AsyncValue.data(updatedInfo); + } + + /// Clear user info + /// + /// Resets the state to loading. + /// Useful on logout or session expiry. + void clear() { + state = const AsyncValue.loading(); + } +} + +// ============================================================================ +// COMPUTED PROVIDERS +// ============================================================================ + +/// User Display Name Provider +/// +/// Provides the user's display name (full name). +/// Returns null if user info is not loaded. +@riverpod +String? userDisplayName(Ref ref) { + final userInfoAsync = ref.watch(userInfoProvider); + return userInfoAsync.value?.fullName; +} + +/// User Loyalty Tier Provider +/// +/// Provides the user's current loyalty tier. +/// Returns null if user info is not loaded. +@riverpod +String? userLoyaltyTier(Ref ref) { + final userInfoAsync = ref.watch(userInfoProvider); + return userInfoAsync.value?.tierDisplayName; +} + +/// User Total Points Provider +/// +/// Provides the user's total loyalty points. +/// Returns 0 if user info is not loaded. +@riverpod +int userTotalPoints(Ref ref) { + final userInfoAsync = ref.watch(userInfoProvider); + return userInfoAsync.value?.totalPoints ?? 0; +} + +/// User Available Points Provider +/// +/// Provides the user's available points for redemption. +/// Returns 0 if user info is not loaded. +@riverpod +int userAvailablePoints(Ref ref) { + final userInfoAsync = ref.watch(userInfoProvider); + return userInfoAsync.value?.availablePoints ?? 0; +} + +/// User Avatar URL Provider +/// +/// Provides the user's avatar URL. +/// Returns null if user info is not loaded or no avatar set. +@riverpod +String? userAvatarUrl(Ref ref) { + final userInfoAsync = ref.watch(userInfoProvider); + return userInfoAsync.value?.avatarUrl; +} + +/// User Has Company Info Provider +/// +/// Checks if the user has company information. +/// Returns false if user info is not loaded. +@riverpod +bool userHasCompanyInfo(Ref ref) { + final userInfoAsync = ref.watch(userInfoProvider); + return userInfoAsync.value?.hasCompanyInfo ?? false; +} + +/// User Is Active Provider +/// +/// Checks if the user's account is active. +/// Returns false if user info is not loaded. +@riverpod +bool userIsActive(Ref ref) { + final userInfoAsync = ref.watch(userInfoProvider); + return userInfoAsync.value?.isActive ?? false; +} diff --git a/lib/features/account/presentation/providers/user_info_provider.g.dart b/lib/features/account/presentation/providers/user_info_provider.g.dart new file mode 100644 index 0000000..039fc32 --- /dev/null +++ b/lib/features/account/presentation/providers/user_info_provider.g.dart @@ -0,0 +1,660 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_info_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// User Info Remote Data Source Provider + +@ProviderFor(userInfoRemoteDataSource) +const userInfoRemoteDataSourceProvider = UserInfoRemoteDataSourceProvider._(); + +/// User Info Remote Data Source Provider + +final class UserInfoRemoteDataSourceProvider + extends + $FunctionalProvider< + AsyncValue, + UserInfoRemoteDataSource, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// User Info Remote Data Source Provider + const UserInfoRemoteDataSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'userInfoRemoteDataSourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$userInfoRemoteDataSourceHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return userInfoRemoteDataSource(ref); + } +} + +String _$userInfoRemoteDataSourceHash() => + r'0005ce1362403c422b0f0c264a532d6e65f8d21f'; + +/// User Info Repository Provider + +@ProviderFor(userInfoRepository) +const userInfoRepositoryProvider = UserInfoRepositoryProvider._(); + +/// User Info Repository Provider + +final class UserInfoRepositoryProvider + extends + $FunctionalProvider< + AsyncValue, + UserInfoRepository, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// User Info Repository Provider + const UserInfoRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'userInfoRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$userInfoRepositoryHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return userInfoRepository(ref); + } +} + +String _$userInfoRepositoryHash() => + r'9dbce126973e282b60cf2437fca1c2c3c3073c0b'; + +/// Get User Info Use Case Provider + +@ProviderFor(getUserInfoUseCase) +const getUserInfoUseCaseProvider = GetUserInfoUseCaseProvider._(); + +/// Get User Info Use Case Provider + +final class GetUserInfoUseCaseProvider + extends + $FunctionalProvider< + AsyncValue, + GetUserInfo, + FutureOr + > + with $FutureModifier, $FutureProvider { + /// Get User Info Use Case Provider + const GetUserInfoUseCaseProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'getUserInfoUseCaseProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$getUserInfoUseCaseHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return getUserInfoUseCase(ref); + } +} + +String _$getUserInfoUseCaseHash() => + r'4da4fa45015bf29b2e8d3fcaf8019eccc470a3c9'; + +/// User Info Provider +/// +/// Fetches and manages user information state. +/// Automatically loads user info on initialization. +/// Provides refresh functionality for manual updates. +/// +/// Usage: +/// ```dart +/// // In a widget +/// final userInfoAsync = ref.watch(userInfoProvider); +/// +/// userInfoAsync.when( +/// data: (userInfo) => Text(userInfo.fullName), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// +/// // To refresh +/// ref.read(userInfoProvider.notifier).refresh(); +/// ``` + +@ProviderFor(UserInfo) +const userInfoProvider = UserInfoProvider._(); + +/// User Info Provider +/// +/// Fetches and manages user information state. +/// Automatically loads user info on initialization. +/// Provides refresh functionality for manual updates. +/// +/// Usage: +/// ```dart +/// // In a widget +/// final userInfoAsync = ref.watch(userInfoProvider); +/// +/// userInfoAsync.when( +/// data: (userInfo) => Text(userInfo.fullName), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// +/// // To refresh +/// ref.read(userInfoProvider.notifier).refresh(); +/// ``` +final class UserInfoProvider + extends $AsyncNotifierProvider { + /// User Info Provider + /// + /// Fetches and manages user information state. + /// Automatically loads user info on initialization. + /// Provides refresh functionality for manual updates. + /// + /// Usage: + /// ```dart + /// // In a widget + /// final userInfoAsync = ref.watch(userInfoProvider); + /// + /// userInfoAsync.when( + /// data: (userInfo) => Text(userInfo.fullName), + /// loading: () => CircularProgressIndicator(), + /// error: (error, stack) => ErrorWidget(error), + /// ); + /// + /// // To refresh + /// ref.read(userInfoProvider.notifier).refresh(); + /// ``` + const UserInfoProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'userInfoProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$userInfoHash(); + + @$internal + @override + UserInfo create() => UserInfo(); +} + +String _$userInfoHash() => r'74fe20082e7acbb23f9606bd01fdf43fd4c5a893'; + +/// User Info Provider +/// +/// Fetches and manages user information state. +/// Automatically loads user info on initialization. +/// Provides refresh functionality for manual updates. +/// +/// Usage: +/// ```dart +/// // In a widget +/// final userInfoAsync = ref.watch(userInfoProvider); +/// +/// userInfoAsync.when( +/// data: (userInfo) => Text(userInfo.fullName), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// +/// // To refresh +/// ref.read(userInfoProvider.notifier).refresh(); +/// ``` + +abstract class _$UserInfo extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref, domain.UserInfo>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, domain.UserInfo>, + AsyncValue, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +/// User Display Name Provider +/// +/// Provides the user's display name (full name). +/// Returns null if user info is not loaded. + +@ProviderFor(userDisplayName) +const userDisplayNameProvider = UserDisplayNameProvider._(); + +/// User Display Name Provider +/// +/// Provides the user's display name (full name). +/// Returns null if user info is not loaded. + +final class UserDisplayNameProvider + extends $FunctionalProvider + with $Provider { + /// User Display Name Provider + /// + /// Provides the user's display name (full name). + /// Returns null if user info is not loaded. + const UserDisplayNameProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'userDisplayNameProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$userDisplayNameHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + String? create(Ref ref) { + return userDisplayName(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(String? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$userDisplayNameHash() => r'610fca82de075602e72988dfbe9a847733dfb9ee'; + +/// User Loyalty Tier Provider +/// +/// Provides the user's current loyalty tier. +/// Returns null if user info is not loaded. + +@ProviderFor(userLoyaltyTier) +const userLoyaltyTierProvider = UserLoyaltyTierProvider._(); + +/// User Loyalty Tier Provider +/// +/// Provides the user's current loyalty tier. +/// Returns null if user info is not loaded. + +final class UserLoyaltyTierProvider + extends $FunctionalProvider + with $Provider { + /// User Loyalty Tier Provider + /// + /// Provides the user's current loyalty tier. + /// Returns null if user info is not loaded. + 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 $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + String? create(Ref ref) { + return userLoyaltyTier(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(String? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$userLoyaltyTierHash() => r'92d69295f4d8e53611bb42e447f71fc3fe3a8514'; + +/// User Total Points Provider +/// +/// Provides the user's total loyalty points. +/// Returns 0 if user info is not loaded. + +@ProviderFor(userTotalPoints) +const userTotalPointsProvider = UserTotalPointsProvider._(); + +/// User Total Points Provider +/// +/// Provides the user's total loyalty points. +/// Returns 0 if user info is not loaded. + +final class UserTotalPointsProvider extends $FunctionalProvider + with $Provider { + /// User Total Points Provider + /// + /// Provides the user's total loyalty points. + /// Returns 0 if user info is not loaded. + 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 $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(value), + ); + } +} + +String _$userTotalPointsHash() => r'9d35a12e7294dc85a5cc754dbd0fb253327195ce'; + +/// User Available Points Provider +/// +/// Provides the user's available points for redemption. +/// Returns 0 if user info is not loaded. + +@ProviderFor(userAvailablePoints) +const userAvailablePointsProvider = UserAvailablePointsProvider._(); + +/// User Available Points Provider +/// +/// Provides the user's available points for redemption. +/// Returns 0 if user info is not loaded. + +final class UserAvailablePointsProvider + extends $FunctionalProvider + with $Provider { + /// User Available Points Provider + /// + /// Provides the user's available points for redemption. + /// Returns 0 if user info is not loaded. + const UserAvailablePointsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'userAvailablePointsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$userAvailablePointsHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + int create(Ref ref) { + return userAvailablePoints(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(int value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$userAvailablePointsHash() => + r'dd3f4952b95c11ccfcbac36622b068cdf8be953a'; + +/// User Avatar URL Provider +/// +/// Provides the user's avatar URL. +/// Returns null if user info is not loaded or no avatar set. + +@ProviderFor(userAvatarUrl) +const userAvatarUrlProvider = UserAvatarUrlProvider._(); + +/// User Avatar URL Provider +/// +/// Provides the user's avatar URL. +/// Returns null if user info is not loaded or no avatar set. + +final class UserAvatarUrlProvider + extends $FunctionalProvider + with $Provider { + /// User Avatar URL Provider + /// + /// Provides the user's avatar URL. + /// Returns null if user info is not loaded or no avatar set. + const UserAvatarUrlProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'userAvatarUrlProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$userAvatarUrlHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + String? create(Ref ref) { + return userAvatarUrl(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(String? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$userAvatarUrlHash() => r'0059015a6651c8794b96aadf6db6196a769d411c'; + +/// User Has Company Info Provider +/// +/// Checks if the user has company information. +/// Returns false if user info is not loaded. + +@ProviderFor(userHasCompanyInfo) +const userHasCompanyInfoProvider = UserHasCompanyInfoProvider._(); + +/// User Has Company Info Provider +/// +/// Checks if the user has company information. +/// Returns false if user info is not loaded. + +final class UserHasCompanyInfoProvider + extends $FunctionalProvider + with $Provider { + /// User Has Company Info Provider + /// + /// Checks if the user has company information. + /// Returns false if user info is not loaded. + const UserHasCompanyInfoProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'userHasCompanyInfoProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$userHasCompanyInfoHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + bool create(Ref ref) { + return userHasCompanyInfo(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$userHasCompanyInfoHash() => + r'fae2791285977a58e8358832b4a3772f99409c8a'; + +/// User Is Active Provider +/// +/// Checks if the user's account is active. +/// Returns false if user info is not loaded. + +@ProviderFor(userIsActive) +const userIsActiveProvider = UserIsActiveProvider._(); + +/// User Is Active Provider +/// +/// Checks if the user's account is active. +/// Returns false if user info is not loaded. + +final class UserIsActiveProvider extends $FunctionalProvider + with $Provider { + /// User Is Active Provider + /// + /// Checks if the user's account is active. + /// Returns false if user info is not loaded. + const UserIsActiveProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'userIsActiveProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$userIsActiveHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + bool create(Ref ref) { + return userIsActive(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$userIsActiveHash() => r'2965221f0518bf7831ab679297f749d1674cb65d'; diff --git a/lib/features/cart/presentation/pages/cart_page.dart b/lib/features/cart/presentation/pages/cart_page.dart index d9a2fed..09259ae 100644 --- a/lib/features/cart/presentation/pages/cart_page.dart +++ b/lib/features/cart/presentation/pages/cart_page.dart @@ -43,12 +43,9 @@ class _CartPageState extends ConsumerState { }); } - @override - void dispose() { - // Force sync any pending quantity updates before leaving cart page - ref.read(cartProvider.notifier).forceSyncPendingUpdates(); - super.dispose(); - } + // Note: Sync is handled in PopScope.onPopInvokedWithResult for back navigation + // and in checkout button handler for checkout flow. + // No dispose() method needed - using ref.read() in dispose() is unsafe. @override Widget build(BuildContext context) { @@ -63,13 +60,21 @@ class _CartPageState extends ConsumerState { final itemCount = cartState.itemCount; final hasSelection = cartState.selectedCount > 0; - return Scaffold( - backgroundColor: const Color(0xFFF4F6F8), - appBar: AppBar( - leading: IconButton( - icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20), - onPressed: () => context.pop(), - ), + return PopScope( + // Intercept back navigation to sync pending updates + onPopInvokedWithResult: (didPop, result) async { + if (didPop) { + // Sync any pending quantity updates before leaving the page + await ref.read(cartProvider.notifier).forceSyncPendingUpdates(); + } + }, + child: Scaffold( + backgroundColor: const Color(0xFFF4F6F8), + appBar: AppBar( + leading: IconButton( + icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20), + onPressed: () => context.pop(), + ), title: Text( 'Giỏ hàng ($itemCount)', style: const TextStyle(color: Colors.black), @@ -144,6 +149,7 @@ class _CartPageState extends ConsumerState { ), ], ), + ), ); } diff --git a/lib/features/cart/data/providers/cart_data_providers.dart b/lib/features/cart/presentation/providers/cart_data_providers.dart similarity index 100% rename from lib/features/cart/data/providers/cart_data_providers.dart rename to lib/features/cart/presentation/providers/cart_data_providers.dart diff --git a/lib/features/cart/data/providers/cart_data_providers.g.dart b/lib/features/cart/presentation/providers/cart_data_providers.g.dart similarity index 100% rename from lib/features/cart/data/providers/cart_data_providers.g.dart rename to lib/features/cart/presentation/providers/cart_data_providers.g.dart diff --git a/lib/features/cart/presentation/providers/cart_provider.dart b/lib/features/cart/presentation/providers/cart_provider.dart index b70ca2d..1c6159b 100644 --- a/lib/features/cart/presentation/providers/cart_provider.dart +++ b/lib/features/cart/presentation/providers/cart_provider.dart @@ -6,7 +6,7 @@ library; import 'dart:async'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:worker/features/cart/data/providers/cart_data_providers.dart'; +import 'package:worker/features/cart/presentation/providers/cart_data_providers.dart'; import 'package:worker/features/cart/presentation/providers/cart_state.dart'; import 'package:worker/features/products/domain/entities/product.dart'; import 'package:worker/features/products/presentation/providers/products_provider.dart'; diff --git a/lib/features/cart/presentation/widgets/cart_item_widget.dart b/lib/features/cart/presentation/widgets/cart_item_widget.dart index 99a7e8c..501f25a 100644 --- a/lib/features/cart/presentation/widgets/cart_item_widget.dart +++ b/lib/features/cart/presentation/widgets/cart_item_widget.dart @@ -22,9 +22,9 @@ import 'package:worker/features/cart/presentation/providers/cart_state.dart'; /// - Quantity controls (-, text field for input, +, unit label) /// - Converted quantity display: "(Quy đổi: X.XX m² = Y viên)" class CartItemWidget extends ConsumerStatefulWidget { - final CartItemData item; const CartItemWidget({super.key, required this.item}); + final CartItemData item; @override ConsumerState createState() => _CartItemWidgetState(); @@ -298,10 +298,10 @@ class _CartItemWidgetState extends ConsumerState { /// /// Matches HTML design with 20px size, 6px radius, blue when checked. class _CustomCheckbox extends StatelessWidget { - final bool value; - final ValueChanged? onChanged; const _CustomCheckbox({required this.value, this.onChanged}); + final bool value; + final ValueChanged? onChanged; @override Widget build(BuildContext context) { @@ -334,10 +334,10 @@ class _CustomCheckbox extends StatelessWidget { /// /// Small button for incrementing/decrementing quantity. class _QuantityButton extends StatelessWidget { - final IconData icon; - final VoidCallback onPressed; const _QuantityButton({required this.icon, required this.onPressed}); + final IconData icon; + final VoidCallback onPressed; @override Widget build(BuildContext context) { diff --git a/pubspec.yaml b/pubspec.yaml index 456df80..cf98506 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+6 +version: 1.0.0+7 environment: sdk: ^3.10.0