diff --git a/docs/user.sh b/docs/user.sh index af94437..2beb56b 100644 --- a/docs/user.sh +++ b/docs/user.sh @@ -4,3 +4,40 @@ curl --location --request POST 'https://land.dbiz.com//api/method/building_mater --header 'X-Frappe-Csrf-Token: a22fa53eeaa923f71f2fd879d2863a0985a6f2107f5f7f66d34cd62d' \ --data '' +#response user info +{ + "message": { + "full_name": "phuoc", + "phone": "0978113710", + "email": "vodanh.2901@gmail.com", + "date_of_birth": null, + "gender": null, + "avatar": "https://secure.gravatar.com/avatar/753a0e2601b9bd87aed417e2ad123bf8?d=404&s=200", + "company_name": "phuoc", + "tax_code": null, + "id_card_front": null, + "id_card_back": null, + "certificates": [], + "membership_status": "Đã được phê duyệt", + "membership_status_color": "Success", + "is_verified": true, + "credential_display": false + } +} + +#update user info +curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.user.update_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' \ +--header 'Content-Type: application/json' \ +--data '{ + "full_name" : "Ha Duy Lam", + "date_of_birth" : "2025-12-30", + "gender" : "Male", + "company_name" : "Ha Duy Lam", + "tax_code" : "0912313232", + "avatar_base64": null, + "id_card_front_base64: null, + "id_card_back_base64: null, + "certificates_base64": [] +}' \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index a5f3c80..20bc755 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -29,9 +29,11 @@ UIMainStoryboardFile Main NSCameraUsageDescription - This app needs camera access to scan QR codes - NSPhotoLibraryUsageDescription - This app needs photos access to get QR code from photo library + Ứng dụng cần quyền truy cập camera để quét mã QR và chụp ảnh giấy tờ xác thực (CCCD/CMND, chứng chỉ hành nghề) + NSPhotoLibraryUsageDescription + Ứng dụng cần quyền truy cập thư viện ảnh để chọn ảnh giấy tờ xác thực và mã QR từ thiết bị của bạn + NSMicrophoneUsageDescription + Ứng dụng cần quyền truy cập microphone để ghi âm video nếu cần UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/lib/features/account/data/datasources/user_info_remote_datasource.dart b/lib/features/account/data/datasources/user_info_remote_datasource.dart index 53deaf4..c15da6c 100644 --- a/lib/features/account/data/datasources/user_info_remote_datasource.dart +++ b/lib/features/account/data/datasources/user_info_remote_datasource.dart @@ -128,4 +128,95 @@ class UserInfoRemoteDataSource { // Could add cache-busting headers in the future if needed return getUserInfo(); } + + /// Update User Info + /// + /// Updates the current user's profile information. + /// + /// API: POST https://land.dbiz.com/api/method/building_material.building_material.api.user.update_user_info + /// + /// Request body: + /// ```json + /// { + /// "full_name": "...", + /// "date_of_birth": "YYYY-MM-DD", + /// "gender": "Male/Female", + /// "company_name": "...", + /// "tax_code": "...", + /// "avatar_base64": null | base64_string, + /// "id_card_front_base64": null | base64_string, + /// "id_card_back_base64": null | base64_string, + /// "certificates_base64": [] | [base64_string, ...] + /// } + /// ``` + /// + /// Throws: + /// - [UnauthorizedException] if user not authenticated (401) + /// - [ServerException] if server error occurs (500+) + /// - [NetworkException] for other network errors + Future updateUserInfo(Map data) async { + try { + debugPrint('🔵 [UserInfoDataSource] Updating user info...'); + debugPrint('🔵 [UserInfoDataSource] Data: $data'); + final startTime = DateTime.now(); + + // Make POST request with update data + final response = await _dioClient.post>( + '/api/method/building_material.building_material.api.user.update_user_info', + data: data, + ); + + final duration = DateTime.now().difference(startTime); + debugPrint('🟢 [UserInfoDataSource] Update response received in ${duration.inMilliseconds}ms'); + debugPrint('🟢 [UserInfoDataSource] Status: ${response.statusCode}'); + + // Check response status + if (response.statusCode == 200) { + // After successful update, fetch fresh user info + debugPrint('✅ [UserInfoDataSource] Successfully updated user info'); + return await getUserInfo(); + } else { + throw ServerException( + 'Failed to update 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('Update 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 update 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'); + } + } } diff --git a/lib/features/account/data/models/user_info_model.dart b/lib/features/account/data/models/user_info_model.dart index 34538b4..bcc1071 100644 --- a/lib/features/account/data/models/user_info_model.dart +++ b/lib/features/account/data/models/user_info_model.dart @@ -27,6 +27,15 @@ class UserInfoModel { this.taxId, this.address, this.cccd, + this.dateOfBirth, + this.gender, + this.idCardFront, + this.idCardBack, + this.certificates = const [], + this.membershipStatus, + this.membershipStatusColor, + this.isVerified = false, + this.credentialDisplay = false, this.referralCode, this.erpnextCustomerId, this.createdAt, @@ -48,6 +57,15 @@ class UserInfoModel { final String? taxId; final String? address; final String? cccd; + final DateTime? dateOfBirth; + final String? gender; + final String? idCardFront; + final String? idCardBack; + final List certificates; + final String? membershipStatus; + final String? membershipStatusColor; + final bool isVerified; + final bool credentialDisplay; final String? referralCode; final String? erpnextCustomerId; final DateTime? createdAt; @@ -87,9 +105,9 @@ class UserInfoModel { /// } /// ``` 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; + // API response structure: { "message": { "full_name": "...", ... } } + // Data is directly under 'message', not nested in 'data' + final data = json['message'] as Map? ?? json; return UserInfoModel( // Use email as userId since API doesn't provide user_id @@ -108,11 +126,22 @@ class UserInfoModel { 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 + cccd: null, // CCCD number not in API + dateOfBirth: _parseDateTime(data['date_of_birth'] as String?), + gender: data['gender'] as String?, + idCardFront: data['id_card_front'] as String?, + idCardBack: data['id_card_back'] as String?, + certificates: (data['certificates'] as List?) + ?.map((e) => e.toString()) + .toList() ?? const [], + membershipStatus: data['membership_status'] as String?, + membershipStatusColor: data['membership_status_color'] as String?, + isVerified: data['is_verified'] as bool? ?? false, + credentialDisplay: data['credential_display'] as bool? ?? false, referralCode: null, erpnextCustomerId: null, - createdAt: _parseDateTime(data['date_of_birth'] as String?), - updatedAt: _parseDateTime(data['date_of_birth'] as String?), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), ); } @@ -164,6 +193,15 @@ class UserInfoModel { taxId: taxId, address: address, cccd: cccd, + dateOfBirth: dateOfBirth, + gender: gender, + idCardFront: idCardFront, + idCardBack: idCardBack, + certificates: certificates, + membershipStatus: membershipStatus, + membershipStatusColor: membershipStatusColor, + isVerified: isVerified, + credentialDisplay: credentialDisplay, referralCode: referralCode, erpnextCustomerId: erpnextCustomerId, createdAt: createdAt ?? now, diff --git a/lib/features/account/data/repositories/user_info_repository_impl.dart b/lib/features/account/data/repositories/user_info_repository_impl.dart index ac95b27..c82c997 100644 --- a/lib/features/account/data/repositories/user_info_repository_impl.dart +++ b/lib/features/account/data/repositories/user_info_repository_impl.dart @@ -88,6 +88,41 @@ class UserInfoRepositoryImpl implements UserInfoRepository { throw ServerException('Failed to refresh user info: $e'); } } + + // ========================================================================= + // UPDATE USER INFO + // ========================================================================= + + @override + Future updateUserInfo(Map data) async { + try { + _debugPrint('Updating user info via API'); + _debugPrint('Update data: $data'); + + // Update via remote datasource (will fetch fresh data after update) + final userInfoModel = await remoteDataSource.updateUserInfo(data); + + _debugPrint('Successfully updated user info: ${userInfoModel.fullName}'); + + // Convert model to entity + return userInfoModel.toEntity(); + } on UnauthorizedException catch (e) { + _debugPrint('Unauthorized error on update: $e'); + rethrow; + } on NotFoundException catch (e) { + _debugPrint('Not found error on update: $e'); + rethrow; + } on ServerException catch (e) { + _debugPrint('Server error on update: $e'); + rethrow; + } on NetworkException catch (e) { + _debugPrint('Network error on update: $e'); + rethrow; + } catch (e) { + _debugPrint('Unexpected error on update: $e'); + throw ServerException('Failed to update user info: $e'); + } + } } // ============================================================================ diff --git a/lib/features/account/domain/entities/user_info.dart b/lib/features/account/domain/entities/user_info.dart index 9938007..9965b1c 100644 --- a/lib/features/account/domain/entities/user_info.dart +++ b/lib/features/account/domain/entities/user_info.dart @@ -60,6 +60,33 @@ class UserInfo { /// CCCD/ID card number final String? cccd; + /// Date of birth + final DateTime? dateOfBirth; + + /// Gender + final String? gender; + + /// ID card front image URL + final String? idCardFront; + + /// ID card back image URL + final String? idCardBack; + + /// Certificate image URLs + final List certificates; + + /// Membership verification status text + final String? membershipStatus; + + /// Membership status color indicator + final String? membershipStatusColor; + + /// Whether user is verified + final bool isVerified; + + /// Whether to display credential verification form + final bool credentialDisplay; + /// Referral code final String? referralCode; @@ -88,6 +115,15 @@ class UserInfo { this.taxId, this.address, this.cccd, + this.dateOfBirth, + this.gender, + this.idCardFront, + this.idCardBack, + this.certificates = const [], + this.membershipStatus, + this.membershipStatusColor, + this.isVerified = false, + this.credentialDisplay = false, this.referralCode, this.erpnextCustomerId, required this.createdAt, @@ -151,6 +187,15 @@ class UserInfo { String? taxId, String? address, String? cccd, + DateTime? dateOfBirth, + String? gender, + String? idCardFront, + String? idCardBack, + List? certificates, + String? membershipStatus, + String? membershipStatusColor, + bool? isVerified, + bool? credentialDisplay, String? referralCode, String? erpnextCustomerId, DateTime? createdAt, @@ -172,6 +217,15 @@ class UserInfo { taxId: taxId ?? this.taxId, address: address ?? this.address, cccd: cccd ?? this.cccd, + dateOfBirth: dateOfBirth ?? this.dateOfBirth, + gender: gender ?? this.gender, + idCardFront: idCardFront ?? this.idCardFront, + idCardBack: idCardBack ?? this.idCardBack, + certificates: certificates ?? this.certificates, + membershipStatus: membershipStatus ?? this.membershipStatus, + membershipStatusColor: membershipStatusColor ?? this.membershipStatusColor, + isVerified: isVerified ?? this.isVerified, + credentialDisplay: credentialDisplay ?? this.credentialDisplay, referralCode: referralCode ?? this.referralCode, erpnextCustomerId: erpnextCustomerId ?? this.erpnextCustomerId, createdAt: createdAt ?? this.createdAt, diff --git a/lib/features/account/domain/repositories/user_info_repository.dart b/lib/features/account/domain/repositories/user_info_repository.dart index d403ebb..4e7c1a1 100644 --- a/lib/features/account/domain/repositories/user_info_repository.dart +++ b/lib/features/account/domain/repositories/user_info_repository.dart @@ -32,4 +32,27 @@ abstract class UserInfoRepository { /// /// Returns [UserInfo] entity with fresh user data. Future refreshUserInfo(); + + /// Update user information + /// + /// Updates the authenticated user's profile information. + /// + /// [data] should contain: + /// - full_name: String + /// - date_of_birth: String (YYYY-MM-DD format) + /// - gender: String + /// - company_name: String? + /// - tax_code: String? + /// - avatar_base64: String? (base64 encoded image) + /// - id_card_front_base64: String? (base64 encoded image) + /// - id_card_back_base64: String? (base64 encoded image) + /// - certificates_base64: List (array of base64 encoded images) + /// + /// Returns updated [UserInfo] entity after successful update. + /// + /// Throws: + /// - [UnauthorizedException] if session expired + /// - [NetworkException] if network error occurs + /// - [ServerException] if server error occurs + Future updateUserInfo(Map data); } diff --git a/lib/features/account/presentation/pages/profile_edit_page.dart b/lib/features/account/presentation/pages/profile_edit_page.dart index ec5a491..f72fa9b 100644 --- a/lib/features/account/presentation/pages/profile_edit_page.dart +++ b/lib/features/account/presentation/pages/profile_edit_page.dart @@ -8,6 +8,7 @@ /// - Save/cancel actions library; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -15,7 +16,9 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image/image.dart' as img; import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.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; @@ -128,16 +131,46 @@ class ProfileEditPage extends HookConsumerWidget { 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 ?? ''); + + // Format date of birth if available + final birthDateText = userInfo.dateOfBirth != null + ? '${userInfo.dateOfBirth!.day.toString().padLeft(2, '0')}/${userInfo.dateOfBirth!.month.toString().padLeft(2, '0')}/${userInfo.dateOfBirth!.year}' + : ''; + final birthDateController = useTextEditingController(text: birthDateText); + 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 + + // Update birthDateController when userInfo changes + useEffect(() { + if (userInfo.dateOfBirth != null) { + final formattedDate = '${userInfo.dateOfBirth!.day.toString().padLeft(2, '0')}/${userInfo.dateOfBirth!.month.toString().padLeft(2, '0')}/${userInfo.dateOfBirth!.year}'; + birthDateController.text = formattedDate; + } + return null; + }, [userInfo.dateOfBirth]); // Dropdown values - final selectedGender = useState('male'); // TODO: Add gender to API - final selectedPosition = useState('contractor'); // TODO: Map from userInfo.role + final selectedGender = useState(userInfo.gender ?? 'male'); + + // Verification images + final idCardFrontImage = useState(null); + final idCardBackImage = useState(null); + final certificateImages = useState>([]); + + // Tab controller and selected index (dynamic length based on credential_display) + final tabLength = userInfo.credentialDisplay ? 2 : 1; + final tabController = useTabController(initialLength: tabLength); + final selectedTabIndex = useState(0); + + // Listen to tab changes + useEffect(() { + void listener() { + selectedTabIndex.value = tabController.index; + } + tabController.addListener(listener); + return () => tabController.removeListener(listener); + }, [tabController]); return PopScope( canPop: !hasChanges.value, @@ -188,18 +221,133 @@ class ProfileEditPage extends HookConsumerWidget { children: [ const SizedBox(height: AppSpacing.md), - // Profile Avatar Section - _buildAvatarSection( + // Profile Avatar Section with Name and Status + _buildAvatarAndStatusSection( context, selectedImage, userInfo.initials, userInfo.avatarUrl, + userInfo.fullName, + userInfo.role.toString().split('.').last, // Extract role name + userInfo.membershipStatus, + userInfo.membershipStatusColor, ), const SizedBox(height: AppSpacing.md), - // Form Card - Container( + // Tab Bar (only show if credential_display is true, otherwise just show info) + if (userInfo.credentialDisplay) + Container( + margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppRadius.card), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: TabBar( + controller: tabController, + indicator: BoxDecoration( + color: AppColors.primaryBlue, + borderRadius: BorderRadius.circular(AppRadius.card), + ), + indicatorSize: TabBarIndicatorSize.tab, + dividerColor: Colors.transparent, + labelColor: Colors.white, + unselectedLabelColor: AppColors.grey500, + labelStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + tabs: const [ + Tab(text: 'Thông tin'), + Tab(text: 'Xác thực'), + ], + ), + ), + + if (userInfo.credentialDisplay) const SizedBox(height: AppSpacing.md), + + // Tab Content (conditionally rendered based on selected tab) + if (!userInfo.credentialDisplay || selectedTabIndex.value == 0) + // Tab 1: Personal Information (always show if no tabs, or when selected) + _buildPersonalInformationTab( + ref: ref, + nameController: nameController, + phoneController: phoneController, + emailController: emailController, + birthDateController: birthDateController, + taxIdController: taxIdController, + companyController: companyController, + selectedGender: selectedGender, + hasChanges: hasChanges, + context: context, + formKey: formKey, + selectedImage: selectedImage, + idCardFrontImage: idCardFrontImage, + idCardBackImage: idCardBackImage, + certificateImages: certificateImages, + ) + else + // Tab 2: Verification (only if credential_display is true) + _buildVerificationTab( + ref: ref, + context: context, + idCardFrontImage: idCardFrontImage, + idCardBackImage: idCardBackImage, + certificateImages: certificateImages, + isVerified: userInfo.isVerified, + existingIdCardFrontUrl: userInfo.idCardFront, + existingIdCardBackUrl: userInfo.idCardBack, + existingCertificateUrls: userInfo.certificates, + formKey: formKey, + hasChanges: hasChanges, + nameController: nameController, + birthDateController: birthDateController, + selectedGender: selectedGender, + companyController: companyController, + taxIdController: taxIdController, + selectedImage: selectedImage, + ), + + const SizedBox(height: AppSpacing.lg), + ], + ), + ), + ), + ), + ); + }, + ); + } + + /// Build Personal Information Tab + Widget _buildPersonalInformationTab({ + required WidgetRef ref, + required TextEditingController nameController, + required TextEditingController phoneController, + required TextEditingController emailController, + required TextEditingController birthDateController, + required TextEditingController taxIdController, + required TextEditingController companyController, + required ValueNotifier selectedGender, + required ValueNotifier hasChanges, + required BuildContext context, + required GlobalKey formKey, + required ValueNotifier selectedImage, + required ValueNotifier idCardFrontImage, + required ValueNotifier idCardBackImage, + required ValueNotifier> certificateImages, + }) { + return Column( + children: [ + // Personal Information Section + Container( margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md), padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( @@ -216,6 +364,28 @@ class ProfileEditPage extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Section Header + const Row( + children: [ + FaIcon( + FontAwesomeIcons.circleUser, + color: AppColors.primaryBlue, + size: 20, + ), + SizedBox(width: 12), + Text( + 'Thông tin cá nhân', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF212121), + ), + ), + ], + ), + + const Divider(height: 32), + // Full Name _buildTextField( label: 'Họ và tên', @@ -231,27 +401,20 @@ class ProfileEditPage extends HookConsumerWidget { const SizedBox(height: AppSpacing.md), - // Phone + // Phone (Read-only) _buildTextField( label: 'Số điện thoại', controller: phoneController, - required: true, - keyboardType: TextInputType.phone, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Vui lòng nhập số điện thoại'; - } - return null; - }, + readOnly: true, ), const SizedBox(height: AppSpacing.md), - // Email + // Email (Read-only) _buildTextField( label: 'Email', controller: emailController, - keyboardType: TextInputType.emailAddress, + readOnly: true, ), const SizedBox(height: AppSpacing.md), @@ -261,6 +424,7 @@ class ProfileEditPage extends HookConsumerWidget { context: context, label: 'Ngày sinh', controller: birthDateController, + hasChanges: hasChanges, ), const SizedBox(height: AppSpacing.md), @@ -270,9 +434,9 @@ class ProfileEditPage extends HookConsumerWidget { label: 'Giới tính', value: selectedGender.value, items: const [ - {'value': 'male', 'label': 'Nam'}, - {'value': 'female', 'label': 'Nữ'}, - {'value': 'other', 'label': 'Khác'}, + {'value': 'Male', 'label': 'Nam'}, + {'value': 'Female', 'label': 'Nữ'}, + {'value': 'Other', 'label': 'Khác'}, ], onChanged: (value) { if (value != null) { @@ -284,11 +448,10 @@ class ProfileEditPage extends HookConsumerWidget { const SizedBox(height: AppSpacing.md), - // ID Number + // Company Name _buildTextField( - label: 'Số CMND/CCCD', - controller: idNumberController, - keyboardType: TextInputType.number, + label: 'Tên công ty/Cửa hàng', + controller: companyController, ), const SizedBox(height: AppSpacing.md), @@ -298,52 +461,37 @@ class ProfileEditPage extends HookConsumerWidget { label: 'Mã số thuế', controller: taxIdController, ), + ], + ), + ), - const SizedBox(height: AppSpacing.md), + const SizedBox(height: AppSpacing.md), - // Company - _buildTextField( - label: 'Công ty', - controller: companyController, + // Info Note about Read-only Fields + Container( + margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(AppRadius.card), + border: Border.all(color: Colors.blue), + ), + child: Row( + children: [ + const FaIcon( + FontAwesomeIcons.circleInfo, + color: AppColors.primaryBlue, + size: 16, ), - - const SizedBox(height: AppSpacing.md), - - // Address - _buildTextField( - label: 'Địa chỉ', - controller: addressController, - maxLines: 2, - ), - - const SizedBox(height: AppSpacing.md), - - // Position - _buildDropdownField( - label: 'Chức vụ', - value: selectedPosition.value, - items: const [ - {'value': 'contractor', 'label': 'Thầu thợ'}, - {'value': 'architect', 'label': 'Kiến trúc sư'}, - {'value': 'dealer', 'label': 'Đại lý phân phối'}, - {'value': 'broker', 'label': 'Môi giới'}, - {'value': 'other', 'label': 'Khác'}, - ], - onChanged: (value) { - if (value != null) { - selectedPosition.value = value; - hasChanges.value = true; - } - }, - ), - - const SizedBox(height: AppSpacing.md), - - // Experience - _buildTextField( - label: 'Kinh nghiệm (năm)', - controller: experienceController, - keyboardType: TextInputType.number, + const SizedBox(width: 8), + Expanded( + child: Text( + 'Để thay đổi số điện thoại, email hoặc vai trò, vui lòng liên hệ bộ phận hỗ trợ.', + style: TextStyle( + fontSize: 12, + color: AppColors.primaryBlue.withValues(alpha: 0.9), + ), + ), ), ], ), @@ -351,105 +499,826 @@ class ProfileEditPage extends HookConsumerWidget { const SizedBox(height: AppSpacing.lg), - // Action Buttons - _buildActionButtons( - context: context, - formKey: formKey, - hasChanges: hasChanges, + // Save Changes Button + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => _saveUserInfo( + context: context, + ref: ref, + formKey: formKey, + hasChanges: hasChanges, + nameController: nameController, + birthDateController: birthDateController, + selectedGender: selectedGender, + companyController: companyController, + taxIdController: taxIdController, + avatarImage: selectedImage.value, + idCardFrontImage: idCardFrontImage.value, + idCardBackImage: idCardBackImage.value, + certificateImages: certificateImages.value, + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + child: const Text( + 'Lưu thay đổi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), ), - const SizedBox(height: AppSpacing.lg), - ], - ), - ), - ), - ), - ); - }, + const SizedBox(height: AppSpacing.lg), + ], ); } - /// Build avatar section with edit button - Widget _buildAvatarSection( - BuildContext context, - ValueNotifier selectedImage, - String initials, - String? avatarUrl, - ) { - 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, + /// Build Verification Tab + Widget _buildVerificationTab({ + required WidgetRef ref, + required BuildContext context, + required ValueNotifier idCardFrontImage, + required ValueNotifier idCardBackImage, + required ValueNotifier> certificateImages, + required bool isVerified, + String? existingIdCardFrontUrl, + String? existingIdCardBackUrl, + List? existingCertificateUrls, + required GlobalKey formKey, + required ValueNotifier hasChanges, + required TextEditingController nameController, + required TextEditingController birthDateController, + required ValueNotifier selectedGender, + required TextEditingController companyController, + required TextEditingController taxIdController, + required ValueNotifier selectedImage, + }) { + return Column( + children: [ + // Info Note + Container( + margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isVerified + ? const Color(0xFFF0FDF4) // Green for verified + : const Color(0xFFEFF6FF), // Blue for not verified + borderRadius: BorderRadius.circular(AppRadius.card), + border: Border.all( + color: isVerified ? const Color(0xFFBBF7D0) : Colors.blue, ), - child: selectedImage.value == null && avatarUrl == null - ? Center( - child: Text( - initials, - style: const TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + ), + child: Row( + children: [ + FaIcon( + isVerified + ? FontAwesomeIcons.circleCheck + : FontAwesomeIcons.circleInfo, + color: isVerified ? AppColors.success : AppColors.primaryBlue, + size: 16, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + isVerified + ? 'Tài khoản của bạn đã được xác thực. Các thông tin xác thực không thể chỉnh sửa.' + : 'Vui lòng cung cấp ảnh chụp rõ ràng các giấy tờ xác thực để được phê duyệt nhanh chóng.', + style: TextStyle( + fontSize: 12, + color: isVerified + ? const Color(0xFF166534) + : AppColors.primaryBlue.withValues(alpha: 0.9), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: AppSpacing.md), + + // Verification Form Card + Container( + margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppRadius.card), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Section Header + const Row( + children: [ + FaIcon( + FontAwesomeIcons.fileCircleCheck, + color: AppColors.primaryBlue, + size: 20, + ), + SizedBox(width: 12), + Text( + 'Thông tin xác thực', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF212121), ), - ) - : null, + ), + ], + ), + + const Divider(height: 32), + + // ID Card Front Upload + const Text( + 'Ảnh mặt trước CCCD/CMND', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 8), + _buildUploadCard( + context: context, + icon: FontAwesomeIcons.camera, + title: 'Chụp ảnh hoặc chọn file', + subtitle: 'JPG, PNG tối đa 5MB', + selectedImage: idCardFrontImage, + existingImageUrl: existingIdCardFrontUrl, + onTap: isVerified + ? null // Disable if verified + : () => _pickVerificationImage(context, idCardFrontImage), + ), + + const SizedBox(height: AppSpacing.md), + + // ID Card Back Upload + const Text( + 'Ảnh mặt sau CCCD/CMND', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 8), + _buildUploadCard( + context: context, + icon: FontAwesomeIcons.camera, + title: 'Chụp ảnh hoặc chọn file', + subtitle: 'JPG, PNG tối đa 5MB', + selectedImage: idCardBackImage, + existingImageUrl: existingIdCardBackUrl, + onTap: isVerified + ? null // Disable if verified + : () => _pickVerificationImage(context, idCardBackImage), + ), + + const SizedBox(height: AppSpacing.md), + + // Certificates Upload (Multiple) + const Text( + 'Chứng chỉ hành nghề', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 8), + _buildMultipleUploadCard( + context: context, + selectedImages: certificateImages, + existingImageUrls: existingCertificateUrls, + isVerified: isVerified, + ), + ], + ), + ), + + const SizedBox(height: AppSpacing.lg), + + // Submit Verification Button (disabled if already verified) + if (!isVerified) + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => _saveUserInfo( + context: context, + ref: ref, + formKey: formKey, + hasChanges: hasChanges, + nameController: nameController, + birthDateController: birthDateController, + selectedGender: selectedGender, + companyController: companyController, + taxIdController: taxIdController, + avatarImage: selectedImage.value, + idCardFrontImage: idCardFrontImage.value, + idCardBackImage: idCardBackImage.value, + certificateImages: certificateImages.value, + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + child: const Text( + 'Gửi xác thực', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), ), - // 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), + const SizedBox(height: AppSpacing.lg), + ], + ); + } + + /// Build upload card for verification files + Widget _buildUploadCard({ + required BuildContext context, + required IconData icon, + required String title, + required String subtitle, + required ValueNotifier selectedImage, + String? existingImageUrl, + VoidCallback? onTap, + }) { + final hasLocalImage = selectedImage.value != null; + final hasExistingImage = existingImageUrl != null && existingImageUrl.isNotEmpty; + final hasAnyImage = hasLocalImage || hasExistingImage; + final isDisabled = onTap == null; + + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: hasAnyImage + ? const Color(0xFFF0FDF4) + : isDisabled + ? const Color(0xFFF1F5F9) // Gray for disabled + : const Color(0xFFF8FAFC), + border: Border.all( + color: hasAnyImage + ? const Color(0xFFBBF7D0) + : isDisabled + ? const Color(0xFFCBD5E1) + : const Color(0xFFE2E8F0), + width: 2, + style: BorderStyle.solid, + ), + borderRadius: BorderRadius.circular(AppRadius.card), + ), + child: hasAnyImage + ? Column( + children: [ + // Image preview + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: hasLocalImage + ? Image.file( + selectedImage.value!, + height: 200, + width: double.infinity, + fit: BoxFit.cover, + ) + : Image.network( + existingImageUrl!, + height: 200, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 120, + color: AppColors.grey100, + child: const Center( + child: FaIcon( + FontAwesomeIcons.circleExclamation, + color: AppColors.grey500, + size: 32, + ), + ), + ); + }, + ), + ), + const SizedBox(height: 12), + // Success indicator + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FaIcon( + FontAwesomeIcons.circleCheck, + color: AppColors.success, + size: 18, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + hasLocalImage + ? selectedImage.value!.path.split('/').last + : 'Đã tải lên', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.success, + ), + overflow: TextOverflow.ellipsis, + ), ), ], ), + const SizedBox(height: 4), + if (!isDisabled) + const Text( + 'Nhấn để thay đổi', + style: TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + ), + ], + ) + : Column( + children: [ + FaIcon( + icon, + color: isDisabled ? AppColors.grey500 : AppColors.grey500, + size: 32, + ), + const SizedBox(height: 8), + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isDisabled + ? AppColors.grey500 + : const Color(0xFF1E293B), + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + fontSize: 12, + color: isDisabled ? AppColors.grey500 : AppColors.grey500, + ), + ), + ], + ), + ), + ); + } + + /// Build multiple upload card for certificates (supports multiple images) + Widget _buildMultipleUploadCard({ + required BuildContext context, + required ValueNotifier> selectedImages, + List? existingImageUrls, + required bool isVerified, + }) { + final hasLocalImages = selectedImages.value.isNotEmpty; + final hasExistingImages = existingImageUrls != null && existingImageUrls.isNotEmpty; + final allImages = []; + + // Add existing images from API + if (hasExistingImages) { + for (final url in existingImageUrls) { + allImages.add( + _buildImagePreview( + imageUrl: url, + onRemove: isVerified ? null : () { + // TODO: Mark for removal in API + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Xóa ảnh hiện có sẽ được cập nhật khi lưu'), + duration: Duration(seconds: 2), + ), + ); + }, + ), + ); + } + } + + // Add local images + if (hasLocalImages) { + for (int i = 0; i < selectedImages.value.length; i++) { + final file = selectedImages.value[i]; + allImages.add( + _buildImagePreview( + imageFile: file, + onRemove: isVerified ? null : () { + final updated = List.from(selectedImages.value); + updated.removeAt(i); + selectedImages.value = updated; + }, + ), + ); + } + } + + return Column( + children: [ + // Display grid of images if any + if (allImages.isNotEmpty) ...[ + Wrap( + spacing: 8, + runSpacing: 8, + children: allImages, + ), + const SizedBox(height: 12), + ], + + // Add button (always show if not verified) + if (!isVerified) + GestureDetector( + onTap: () => _pickMultipleCertificateImages(context, selectedImages), + child: Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + border: Border.all( + color: const Color(0xFFE2E8F0), + width: 2, + ), + borderRadius: BorderRadius.circular(AppRadius.card), + ), + child: Column( + children: [ + const FaIcon( + FontAwesomeIcons.folderPlus, + color: AppColors.grey500, + size: 32, + ), + const SizedBox(height: 8), + Text( + allImages.isEmpty ? 'Chọn ảnh chứng chỉ' : 'Thêm ảnh chứng chỉ', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 4), + const Text( + 'Có thể chọn nhiều ảnh - JPG, PNG tối đa 5MB mỗi ảnh', + style: TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + + // Read-only message if verified + if (isVerified && allImages.isEmpty) + Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + border: Border.all( + color: const Color(0xFFCBD5E1), + width: 2, + ), + borderRadius: BorderRadius.circular(AppRadius.card), + ), + child: const Column( + children: [ + FaIcon( + FontAwesomeIcons.certificate, + color: AppColors.grey500, + size: 32, + ), + SizedBox(height: 8), + Text( + 'Chưa có chứng chỉ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.grey500, + ), + ), + ], + ), + ), + ], + ); + } + + /// Build image preview with remove button + Widget _buildImagePreview({ + File? imageFile, + String? imageUrl, + VoidCallback? onRemove, + }) { + return Container( + width: 100, + height: 100, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: const Color(0xFFBBF7D0), + width: 2, + ), + ), + child: Stack( + children: [ + // Image + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: imageFile != null + ? Image.file( + imageFile, + width: 100, + height: 100, + fit: BoxFit.cover, + ) + : Image.network( + imageUrl!, + width: 100, + height: 100, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: AppColors.grey100, + child: const Center( + child: FaIcon( + FontAwesomeIcons.circleExclamation, + color: AppColors.grey500, + size: 24, + ), + ), + ); + }, + ), + ), + + // Remove button + if (onRemove != null) + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: onRemove, + child: Container( + width: 24, + height: 24, + decoration: const BoxDecoration( + color: AppColors.danger, + shape: BoxShape.circle, + ), child: const Center( child: FaIcon( - FontAwesomeIcons.camera, - size: 16, + FontAwesomeIcons.xmark, + size: 12, color: Colors.white, ), ), ), ), ), - ], - ), + ], + ), + ); + } + + /// Build avatar section with name, position, and status + Widget _buildAvatarAndStatusSection( + BuildContext context, + ValueNotifier selectedImage, + String initials, + String? avatarUrl, + String fullName, + String position, + String? membershipStatus, + String? membershipStatusColor, + ) { + // Map position to Vietnamese labels + final positionLabels = { + 'contractor': 'Thầu thợ', + 'architect': 'Kiến trúc sư', + 'distributor': 'Đại lý phân phối', + 'broker': 'Môi giới', + }; + + // Map status color to badge colors + final statusColors = { + 'Success': { + 'bg': const Color(0xFFF0FDF4), // Green background + 'border': const Color(0xFFBBF7D0), // Green border + 'icon': const Color(0xFF16A34A), // Green icon + 'text': const Color(0xFF166534), // Green text + }, + 'Warning': { + 'bg': const Color(0xFFFEF3C7), // Yellow background + 'border': const Color(0xFFFDE68A), // Yellow border + 'icon': const Color(0xFFEAB308), // Yellow icon + 'text': const Color(0xFF854D0E), // Yellow text + }, + 'Danger': { + 'bg': const Color(0xFFFEE2E2), // Red background + 'border': const Color(0xFFFECACA), // Red border + 'icon': const Color(0xFFB91C1C), // Red icon + 'text': const Color(0xFFB91C1C), // Red text + }, + }; + + final colors = statusColors[membershipStatusColor] ?? statusColors['Danger']!; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppRadius.card), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Avatar with camera button + Stack( + clipBehavior: Clip.none, + children: [ + // Avatar + Container( + width: 96, + height: 96, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.primaryBlue, + border: Border.all(color: Colors.white, width: 4), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + 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, + ), + + // Camera button + Positioned( + bottom: -2, + right: -2, + 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: 3), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: const Center( + child: FaIcon( + FontAwesomeIcons.camera, + size: 14, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Name + Text( + fullName, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF212121), + ), + ), + + const SizedBox(height: 4), + + // Position + Text( + positionLabels[position] ?? position, + style: const TextStyle( + fontSize: 14, + color: AppColors.grey500, + ), + ), + + const SizedBox(height: 16), + + // Account Status Badge (from API) + if (membershipStatus != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colors['bg'] as Color, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FaIcon( + membershipStatusColor == 'Success' + ? FontAwesomeIcons.circleCheck + : membershipStatusColor == 'Warning' + ? FontAwesomeIcons.clock + : FontAwesomeIcons.circleExclamation, + color: colors['icon'] as Color, + size: 12, + ), + const SizedBox(width: 6), + Text( + membershipStatus, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: colors['text'] as Color, + ), + ), + ], + ), + ), + ], ), ); } @@ -459,6 +1328,7 @@ class ProfileEditPage extends HookConsumerWidget { required String label, required TextEditingController controller, bool required = false, + bool readOnly = false, TextInputType? keyboardType, int maxLines = 1, String? Function(String?)? validator, @@ -488,6 +1358,7 @@ class ProfileEditPage extends HookConsumerWidget { controller: controller, keyboardType: keyboardType, maxLines: maxLines, + readOnly: readOnly, validator: validator, decoration: InputDecoration( hintText: 'Nhập $label', @@ -496,7 +1367,7 @@ class ProfileEditPage extends HookConsumerWidget { fontSize: 14, ), filled: true, - fillColor: const Color(0xFFF8FAFC), + fillColor: readOnly ? const Color(0xFFF1F5F9) : const Color(0xFFF8FAFC), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, @@ -535,6 +1406,7 @@ class ProfileEditPage extends HookConsumerWidget { required BuildContext context, required String label, required TextEditingController controller, + ValueNotifier? hasChanges, }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -548,19 +1420,28 @@ class ProfileEditPage extends HookConsumerWidget { ), ), const SizedBox(height: 8), - TextFormField( + TextField( controller: controller, readOnly: true, + style: const TextStyle( + fontSize: 14, + color: Color(0xFF1E293B), + fontWeight: FontWeight.w400, + ), onTap: () async { final date = await showDatePicker( context: context, - initialDate: DateTime(1985, 3, 15), + initialDate: DateTime.now(), firstDate: DateTime(1940), lastDate: DateTime.now(), ); if (date != null) { controller.text = '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; + // Mark as changed + if (hasChanges != null) { + hasChanges.value = true; + } } }, decoration: InputDecoration( @@ -573,19 +1454,12 @@ class ProfileEditPage extends HookConsumerWidget { fillColor: const Color(0xFFF8FAFC), contentPadding: const EdgeInsets.symmetric( horizontal: 16, - vertical: 12, + vertical: 16, ), - suffixIcon: const Padding( - padding: EdgeInsets.only(right: 12), - child: FaIcon( - FontAwesomeIcons.calendar, - size: 20, - color: AppColors.grey500, - ), - ), - suffixIconConstraints: const BoxConstraints( - minWidth: 48, - minHeight: 48, + suffixIcon: const Icon( + Icons.calendar_today, + size: 20, + color: AppColors.grey500, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.input), @@ -672,82 +1546,141 @@ class ProfileEditPage extends HookConsumerWidget { ); } - /// Build action buttons - Widget _buildActionButtons({ - required BuildContext context, - required GlobalKey formKey, - required ValueNotifier hasChanges, - }) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), - child: Row( - children: [ - // Cancel Button - Expanded( - child: OutlinedButton( - onPressed: () { - context.pop(); - }, - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - side: const BorderSide(color: AppColors.grey100), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.button), - ), - ), - child: const Text( - 'Hủy bỏ', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.grey900, - ), - ), - ), - ), + /// Pick verification image from gallery or camera + Future _pickVerificationImage( + BuildContext context, + ValueNotifier selectedImage, + ) async { + final ImagePicker picker = ImagePicker(); - const SizedBox(width: AppSpacing.sm), - - // Save Button - Expanded( - flex: 2, - child: ElevatedButton.icon( - onPressed: () { - if (formKey.currentState?.validate() ?? false) { - // TODO: Save profile data - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Thông tin đã được cập nhật thành công!'), - backgroundColor: AppColors.success, - duration: Duration(seconds: 2), - ), - ); - hasChanges.value = false; - context.pop(); - } - }, - icon: const FaIcon(FontAwesomeIcons.floppyDisk, size: 18), - label: const Text( - 'Lưu thay đổi', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryBlue, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.button), - ), - ), + // Show dialog to choose source + final source = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Chọn ảnh từ'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const FaIcon(FontAwesomeIcons.camera, size: 18), + title: const Text('Máy ảnh'), + onTap: () => Navigator.pop(context, ImageSource.camera), ), - ), - ], + ListTile( + leading: const FaIcon(FontAwesomeIcons.images, size: 18), + title: const Text('Thư viện ảnh'), + onTap: () => Navigator.pop(context, ImageSource.gallery), + ), + ], + ), ), ); + + if (source != null) { + final XFile? image = await picker.pickImage( + source: source, + maxWidth: 1024, + maxHeight: 1024, + imageQuality: 85, + ); + + if (image != null) { + selectedImage.value = File(image.path); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Đã chọn ảnh thành công'), + backgroundColor: AppColors.success, + duration: Duration(seconds: 2), + ), + ); + } + } + } + } + + /// Pick multiple certificate images from gallery + Future _pickMultipleCertificateImages( + BuildContext context, + ValueNotifier> selectedImages, + ) async { + final ImagePicker picker = ImagePicker(); + + try { + // Pick multiple images from gallery + final List images = await picker.pickMultiImage( + maxWidth: 1024, + maxHeight: 1024, + imageQuality: 85, + ); + + if (images.isNotEmpty) { + // Convert XFile to File, fix orientation, and add to existing list + final List fixedFiles = []; + for (final xfile in images) { + final originalFile = File(xfile.path); + final fixedFile = await _fixImageOrientation(originalFile); + fixedFiles.add(fixedFile); + } + + final updated = List.from(selectedImages.value); + updated.addAll(fixedFiles); + selectedImages.value = updated; + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Đã chọn ${images.length} ảnh thành công'), + backgroundColor: AppColors.success, + duration: const Duration(seconds: 2), + ), + ); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Không thể chọn ảnh. Vui lòng thử lại.'), + backgroundColor: AppColors.danger, + duration: Duration(seconds: 2), + ), + ); + } + } } /// Pick image from gallery or camera + /// Fix image orientation based on EXIF data + Future _fixImageOrientation(File imageFile) async { + try { + // Read the image bytes + final bytes = await imageFile.readAsBytes(); + + // Decode the image (this automatically applies EXIF orientation) + final image = img.decodeImage(bytes); + + if (image == null) { + return imageFile; + } + + // Encode back to JPEG + final fixedBytes = img.encodeJpg(image, quality: 85); + + // Create a temporary file to save the fixed image + final tempDir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final tempFile = File('${tempDir.path}/fixed_image_$timestamp.jpg'); + await tempFile.writeAsBytes(fixedBytes); + + return tempFile; + } catch (e) { + // If orientation fix fails, return original file + debugPrint('Error fixing image orientation: $e'); + return imageFile; + } + } + Future _pickImage( BuildContext context, ValueNotifier selectedImage, @@ -780,17 +1713,131 @@ class ProfileEditPage extends HookConsumerWidget { if (source != null) { final XFile? image = await picker.pickImage( source: source, - maxWidth: 512, - maxHeight: 512, + maxWidth: 1024, + maxHeight: 1024, imageQuality: 85, ); if (image != null) { - selectedImage.value = File(image.path); + // Fix orientation before setting the image + final originalFile = File(image.path); + final fixedFile = await _fixImageOrientation(originalFile); + selectedImage.value = fixedFile; } } } + /// Convert File to base64 string + Future _fileToBase64(File? file) async { + if (file == null) return null; + try { + final bytes = await file.readAsBytes(); + return base64Encode(bytes); + } catch (e) { + return null; + } + } + + /// Unified save function for both personal info and verification + Future _saveUserInfo({ + required BuildContext context, + required WidgetRef ref, + required GlobalKey formKey, + required ValueNotifier hasChanges, + required TextEditingController nameController, + required TextEditingController birthDateController, + required ValueNotifier selectedGender, + required TextEditingController companyController, + required TextEditingController taxIdController, + File? avatarImage, + File? idCardFrontImage, + File? idCardBackImage, + List? certificateImages, + }) async { + // Validate form + if (!(formKey.currentState?.validate() ?? false)) { + return; + } + + try { + // Convert images to base64 + final avatarBase64 = await _fileToBase64(avatarImage); + final idCardFrontBase64 = await _fileToBase64(idCardFrontImage); + final idCardBackBase64 = await _fileToBase64(idCardBackImage); + + // Convert certificate images to base64 list + final List certificatesBase64 = []; + if (certificateImages != null && certificateImages.isNotEmpty) { + for (final file in certificateImages) { + final base64 = await _fileToBase64(file); + if (base64 != null) { + certificatesBase64.add(base64); + } + } + } + + // Prepare update data + final updateData = { + 'full_name': nameController.text, + 'date_of_birth': birthDateController.text.isNotEmpty + ? _formatDateForApi(birthDateController.text) + : null, + 'gender': selectedGender.value, + 'company_name': companyController.text.isNotEmpty + ? companyController.text + : null, + 'tax_code': taxIdController.text.isNotEmpty + ? taxIdController.text + : null, + 'avatar_base64': avatarBase64, + 'id_card_front_base64': idCardFrontBase64, + 'id_card_back_base64': idCardBackBase64, + 'certificates_base64': certificatesBase64, + }; + + // Call API to update user info + await ref.read(userInfoProvider.notifier).updateUserInfo(updateData); + + if (context.mounted) { + // Mark as saved + hasChanges.value = false; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Thông tin đã được cập nhật thành công!'), + backgroundColor: AppColors.success, + duration: Duration(seconds: 2), + ), + ); + // context.pop(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Lỗi khi cập nhật thông tin: $e'), + backgroundColor: AppColors.danger, + duration: const Duration(seconds: 3), + ), + ); + } + } + } + + /// Format date from DD/MM/YYYY to YYYY-MM-DD for API + String? _formatDateForApi(String dateText) { + if (dateText.isEmpty) return null; + try { + final parts = dateText.split('/'); + if (parts.length == 3) { + return '${parts[2]}-${parts[1]}-${parts[0]}'; // YYYY-MM-DD + } + return null; + } catch (e) { + return null; + } + } + /// Show unsaved changes dialog Future _showUnsavedChangesDialog(BuildContext context) { return showDialog( diff --git a/lib/features/account/presentation/providers/user_info_provider.dart b/lib/features/account/presentation/providers/user_info_provider.dart index a0a2cf8..24b855a 100644 --- a/lib/features/account/presentation/providers/user_info_provider.dart +++ b/lib/features/account/presentation/providers/user_info_provider.dart @@ -103,6 +103,36 @@ class UserInfo extends _$UserInfo { }); } + /// Update user information + /// + /// Sends updated user data to the API and refreshes the state. + /// + /// [data] should contain: + /// - full_name: String + /// - date_of_birth: String (YYYY-MM-DD) + /// - gender: String + /// - company_name: String? + /// - tax_code: String? + /// - avatar_base64: String? (base64 encoded) + /// - id_card_front_base64: String? (base64 encoded) + /// - id_card_back_base64: String? (base64 encoded) + /// - certificates_base64: List (base64 encoded) + /// + /// Usage: + /// ```dart + /// await ref.read(userInfoProvider.notifier).updateUserInfo(updateData); + /// ``` + Future updateUserInfo(Map data) async { + // Set loading state + state = const AsyncValue.loading(); + + // Update via repository and fetch fresh data + state = await AsyncValue.guard(() async { + final repository = await ref.read(userInfoRepositoryProvider.future); + return await repository.updateUserInfo(data); + }); + } + /// Update user info locally /// /// Updates the cached state without fetching from API. diff --git a/lib/features/account/presentation/providers/user_info_provider.g.dart b/lib/features/account/presentation/providers/user_info_provider.g.dart index 039fc32..87b8850 100644 --- a/lib/features/account/presentation/providers/user_info_provider.g.dart +++ b/lib/features/account/presentation/providers/user_info_provider.g.dart @@ -232,7 +232,7 @@ final class UserInfoProvider UserInfo create() => UserInfo(); } -String _$userInfoHash() => r'74fe20082e7acbb23f9606bd01fdf43fd4c5a893'; +String _$userInfoHash() => r'ed28fdf0213dfd616592b9735cd291f147867047'; /// User Info Provider /// diff --git a/pubspec.lock b/pubspec.lock index 7707914..726ffef 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -711,6 +711,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" image_picker: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index cf98506..8a07240 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,6 +70,7 @@ dependencies: intl: ^0.20.0 share_plus: ^12.0.1 image_picker: ^1.1.2 + image: ^4.5.4 file_picker: ^8.0.0 url_launcher: ^6.3.0 path_provider: ^2.1.3