Files
worker/lib/features/account/presentation/pages/profile_edit_page.dart
Phuoc Nguyen 19d9a3dc2d update loaing
2025-12-02 18:09:20 +07:00

1877 lines
65 KiB
Dart

/// Profile Edit Page
///
/// Allows users to edit their profile information.
/// Features:
/// - Avatar upload with image picker
/// - Form fields for personal information
/// - Form validation
/// - Save/cancel actions
library;
import 'dart:convert';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'dart:io';
import 'package:flutter/material.dart';
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;
/// Profile Edit Page
///
/// Page for editing user profile information with avatar upload.
class ProfileEditPage extends HookConsumerWidget {
const ProfileEditPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Watch user info from API
final userInfoAsync = ref.watch(userInfoProvider);
// Form key for validation
final formKey = useMemoized(() => GlobalKey<FormState>());
// Image picker
final selectedImage = useState<File?>(null);
// Has unsaved changes
final hasChanges = useState<bool>(false);
return userInfoAsync.when(
loading: () => Scaffold(
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
backgroundColor: colorScheme.surface,
elevation: 0,
title: Text(
'Thông tin cá nhân',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
centerTitle: false,
),
body: const CustomLoadingIndicator(
message: 'Đang tải thông tin...',
),
),
error: (error, stack) => Scaffold(
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
backgroundColor: colorScheme.surface,
elevation: 0,
leading: IconButton(
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
onPressed: () => context.pop(),
),
title: Text(
'Thông tin cá nhân',
style: TextStyle(
color: colorScheme.onSurface,
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),
Text(
'Không thể tải thông tin người dùng',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
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: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
),
],
),
),
),
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 ?? '');
// 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 ?? '');
// 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<String>(userInfo.gender ?? 'male');
// Verification images
final idCardFrontImage = useState<File?>(null);
final idCardBackImage = useState<File?>(null);
final certificateImages = useState<List<File>>([]);
// 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<int>(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,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _showUnsavedChangesDialog(context);
if (shouldPop == true && context.mounted) {
Navigator.of(context).pop();
}
},
child: Scaffold(
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
backgroundColor: colorScheme.surface,
elevation: 0,
leading: IconButton(
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
onPressed: () async {
if (hasChanges.value) {
final shouldPop = await _showUnsavedChangesDialog(context);
if (shouldPop == true && context.mounted) {
context.pop();
}
} else {
context.pop();
}
},
),
title: Text(
'Thông tin cá nhân',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
centerTitle: false,
actions: const [SizedBox(width: AppSpacing.sm)],
),
body: Form(
key: formKey,
onChanged: () {
hasChanges.value = true;
},
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: AppSpacing.md),
// Profile Avatar Section with Name and Status
_buildAvatarAndStatusSection(
context,
colorScheme,
selectedImage,
userInfo.initials,
userInfo.avatarUrl,
userInfo.fullName,
userInfo.role.toString().split('.').last, // Extract role name
userInfo.membershipStatus,
userInfo.membershipStatusColor,
),
const SizedBox(height: AppSpacing.md),
// 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: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: TabBar(
controller: tabController,
indicator: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(AppRadius.card),
),
indicatorSize: TabBarIndicatorSize.tab,
dividerColor: Colors.transparent,
labelColor: colorScheme.onPrimary,
unselectedLabelColor: colorScheme.onSurfaceVariant,
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,
colorScheme: colorScheme,
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,
colorScheme: colorScheme,
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 ColorScheme colorScheme,
required TextEditingController nameController,
required TextEditingController phoneController,
required TextEditingController emailController,
required TextEditingController birthDateController,
required TextEditingController taxIdController,
required TextEditingController companyController,
required ValueNotifier<String> selectedGender,
required ValueNotifier<bool> hasChanges,
required BuildContext context,
required GlobalKey<FormState> formKey,
required ValueNotifier<File?> selectedImage,
required ValueNotifier<File?> idCardFrontImage,
required ValueNotifier<File?> idCardBackImage,
required ValueNotifier<List<File>> certificateImages,
}) {
return Column(
children: [
// Personal Information Section
Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Header
Row(
children: [
FaIcon(
FontAwesomeIcons.circleUser,
color: colorScheme.primary,
size: 20,
),
const SizedBox(width: 12),
Text(
'Thông tin cá nhân',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
],
),
const Divider(height: 32),
// Full Name
_buildTextField(
colorScheme: colorScheme,
label: 'Họ và tên',
controller: nameController,
required: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vui lòng nhập họ và tên';
}
return null;
},
),
const SizedBox(height: AppSpacing.md),
// Phone (Read-only)
_buildTextField(
colorScheme: colorScheme,
label: 'Số điện thoại',
controller: phoneController,
readOnly: true,
),
const SizedBox(height: AppSpacing.md),
// Email (Read-only)
_buildTextField(
colorScheme: colorScheme,
label: 'Email',
controller: emailController,
readOnly: true,
),
const SizedBox(height: AppSpacing.md),
// Birth Date
_buildDateField(
context: context,
colorScheme: colorScheme,
label: 'Ngày sinh',
controller: birthDateController,
hasChanges: hasChanges,
),
const SizedBox(height: AppSpacing.md),
// Gender
_buildDropdownField(
colorScheme: colorScheme,
label: 'Giới tính',
value: selectedGender.value,
items: const [
{'value': 'Male', 'label': 'Nam'},
{'value': 'Female', 'label': 'Nữ'},
{'value': 'Other', 'label': 'Khác'},
],
onChanged: (value) {
if (value != null) {
selectedGender.value = value;
hasChanges.value = true;
}
},
),
const SizedBox(height: AppSpacing.md),
// Company Name
_buildTextField(
colorScheme: colorScheme,
label: 'Tên công ty/Cửa hàng',
controller: companyController,
),
const SizedBox(height: AppSpacing.md),
// Tax ID
_buildTextField(
colorScheme: colorScheme,
label: 'Mã số thuế',
controller: taxIdController,
),
],
),
),
const SizedBox(height: AppSpacing.md),
// Info Note about Read-only Fields
Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: colorScheme.primary),
),
child: Row(
children: [
FaIcon(
FontAwesomeIcons.circleInfo,
color: colorScheme.primary,
size: 16,
),
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: colorScheme.onPrimaryContainer,
),
),
),
],
),
),
const SizedBox(height: AppSpacing.lg),
// 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: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
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),
],
);
}
/// Build Verification Tab
Widget _buildVerificationTab({
required WidgetRef ref,
required BuildContext context,
required ColorScheme colorScheme,
required ValueNotifier<File?> idCardFrontImage,
required ValueNotifier<File?> idCardBackImage,
required ValueNotifier<List<File>> certificateImages,
required bool isVerified,
String? existingIdCardFrontUrl,
String? existingIdCardBackUrl,
List<String>? existingCertificateUrls,
required GlobalKey<FormState> formKey,
required ValueNotifier<bool> hasChanges,
required TextEditingController nameController,
required TextEditingController birthDateController,
required ValueNotifier<String> selectedGender,
required TextEditingController companyController,
required TextEditingController taxIdController,
required ValueNotifier<File?> 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
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(
color: isVerified ? const Color(0xFFBBF7D0) : colorScheme.primary,
),
),
child: Row(
children: [
FaIcon(
isVerified
? FontAwesomeIcons.circleCheck
: FontAwesomeIcons.circleInfo,
color: isVerified ? AppColors.success : colorScheme.primary,
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)
: colorScheme.onPrimaryContainer,
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: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Section Header
Row(
children: [
FaIcon(
FontAwesomeIcons.fileCircleCheck,
color: colorScheme.primary,
size: 20,
),
const SizedBox(width: 12),
Text(
'Thông tin xác thực',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
],
),
const Divider(height: 32),
// ID Card Front Upload
Text(
'Ảnh mặt trước CCCD/CMND',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
_buildUploadCard(
context: context,
colorScheme: colorScheme,
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
Text(
'Ảnh mặt sau CCCD/CMND',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
_buildUploadCard(
context: context,
colorScheme: colorScheme,
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)
Text(
'Chứng chỉ hành nghề',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
_buildMultipleUploadCard(
context: context,
colorScheme: colorScheme,
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: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
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,
),
),
),
),
),
const SizedBox(height: AppSpacing.lg),
],
);
}
/// Build upload card for verification files
Widget _buildUploadCard({
required BuildContext context,
required ColorScheme colorScheme,
required IconData icon,
required String title,
required String subtitle,
required ValueNotifier<File?> 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
? colorScheme.surfaceContainerHighest
: colorScheme.surfaceContainerLowest,
border: Border.all(
color: hasAnyImage
? const Color(0xFFBBF7D0)
: isDisabled
? colorScheme.outlineVariant
: colorScheme.outlineVariant,
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: colorScheme.onSurfaceVariant,
size: 32,
),
const SizedBox(height: 8),
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDisabled
? colorScheme.onSurfaceVariant
: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
/// Build multiple upload card for certificates (supports multiple images)
Widget _buildMultipleUploadCard({
required BuildContext context,
required ColorScheme colorScheme,
required ValueNotifier<List<File>> selectedImages,
List<String>? existingImageUrls,
required bool isVerified,
}) {
final hasLocalImages = selectedImages.value.isNotEmpty;
final hasExistingImages = existingImageUrls != null && existingImageUrls.isNotEmpty;
final allImages = <Widget>[];
// 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<File>.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: colorScheme.surfaceContainerLowest,
border: Border.all(
color: colorScheme.outlineVariant,
width: 2,
),
borderRadius: BorderRadius.circular(AppRadius.card),
),
child: Column(
children: [
FaIcon(
FontAwesomeIcons.folderPlus,
color: colorScheme.onSurfaceVariant,
size: 32,
),
const SizedBox(height: 8),
Text(
allImages.isEmpty ? 'Chọn ảnh chứng chỉ' : 'Thêm ảnh chứng chỉ',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
'Có thể chọn nhiều ảnh - JPG, PNG tối đa 5MB mỗi ảnh',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
),
// Read-only message if verified
if (isVerified && allImages.isEmpty)
Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
border: Border.all(
color: colorScheme.outlineVariant,
width: 2,
),
borderRadius: BorderRadius.circular(AppRadius.card),
),
child: Column(
children: [
FaIcon(
FontAwesomeIcons.certificate,
color: colorScheme.onSurfaceVariant,
size: 32,
),
const SizedBox(height: 8),
Text(
'Chưa có chứng chỉ',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
);
}
/// 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.xmark,
size: 12,
color: Colors.white,
),
),
),
),
),
],
),
);
}
/// Build avatar section with name, position, and status
Widget _buildAvatarAndStatusSection(
BuildContext context,
ColorScheme colorScheme,
ValueNotifier<File?> 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: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.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: colorScheme.primary,
border: Border.all(color: colorScheme.surface, width: 4),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.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: colorScheme.primary,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.surface, width: 3),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Center(
child: FaIcon(
FontAwesomeIcons.camera,
size: 14,
color: colorScheme.onPrimary,
),
),
),
),
),
],
),
const SizedBox(height: 16),
// Name
Text(
fullName,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
// Position
Text(
positionLabels[position] ?? position,
style: TextStyle(
fontSize: 14,
color: colorScheme.onSurfaceVariant,
),
),
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,
),
),
],
),
),
],
),
);
}
/// Build text field
Widget _buildTextField({
required ColorScheme colorScheme,
required String label,
required TextEditingController controller,
bool required = false,
bool readOnly = false,
TextInputType? keyboardType,
int maxLines = 1,
String? Function(String?)? validator,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colorScheme.onSurface,
),
children: [
if (required)
const TextSpan(
text: ' *',
style: TextStyle(color: AppColors.danger),
),
],
),
),
const SizedBox(height: 8),
TextFormField(
controller: controller,
keyboardType: keyboardType,
maxLines: maxLines,
readOnly: readOnly,
validator: validator,
decoration: InputDecoration(
hintText: 'Nhập $label',
hintStyle: TextStyle(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 14,
),
filled: true,
fillColor: readOnly ? colorScheme.surfaceContainerHighest : colorScheme.surfaceContainerLowest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: AppColors.danger),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: AppColors.danger, width: 2),
),
),
),
],
);
}
/// Build date field
Widget _buildDateField({
required BuildContext context,
required ColorScheme colorScheme,
required String label,
required TextEditingController controller,
ValueNotifier<bool>? hasChanges,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
TextField(
controller: controller,
readOnly: true,
style: TextStyle(
fontSize: 14,
color: colorScheme.onSurface,
fontWeight: FontWeight.w400,
),
onTap: () async {
final date = await showDatePicker(
context: context,
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(
hintText: 'Chọn ngày sinh',
hintStyle: TextStyle(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 14,
),
filled: true,
fillColor: colorScheme.surfaceContainerLowest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
suffixIcon: Icon(
Icons.calendar_today,
size: 20,
color: colorScheme.onSurfaceVariant,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
),
),
],
);
}
/// Build dropdown field
Widget _buildDropdownField({
required ColorScheme colorScheme,
required String label,
required String value,
required List<Map<String, String>> items,
required ValueChanged<String?> onChanged,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: value,
onChanged: onChanged,
icon: Padding(
padding: const EdgeInsets.only(right: 12),
child: FaIcon(
FontAwesomeIcons.chevronDown,
size: 16,
color: colorScheme.onSurfaceVariant,
),
),
decoration: InputDecoration(
filled: true,
fillColor: colorScheme.surfaceContainerLowest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
),
items: items.map((item) {
return DropdownMenuItem<String>(
value: item['value'],
child: Text(item['label']!, style: const TextStyle(fontSize: 14)),
);
}).toList(),
),
],
);
}
/// Pick verification image from gallery or camera
Future<void> _pickVerificationImage(
BuildContext context,
ValueNotifier<File?> selectedImage,
) async {
final ImagePicker picker = ImagePicker();
// Show dialog to choose source
final source = await showDialog<ImageSource>(
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<void> _pickMultipleCertificateImages(
BuildContext context,
ValueNotifier<List<File>> selectedImages,
) async {
final ImagePicker picker = ImagePicker();
try {
// Pick multiple images from gallery
final List<XFile> 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<File> fixedFiles = [];
for (final xfile in images) {
final originalFile = File(xfile.path);
final fixedFile = await _fixImageOrientation(originalFile);
fixedFiles.add(fixedFile);
}
final updated = List<File>.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<File> _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<void> _pickImage(
BuildContext context,
ValueNotifier<File?> selectedImage,
) async {
final ImagePicker picker = ImagePicker();
// Show dialog to choose source
final source = await showDialog<ImageSource>(
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) {
// 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<String?> _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<void> _saveUserInfo({
required BuildContext context,
required WidgetRef ref,
required GlobalKey<FormState> formKey,
required ValueNotifier<bool> hasChanges,
required TextEditingController nameController,
required TextEditingController birthDateController,
required ValueNotifier<String> selectedGender,
required TextEditingController companyController,
required TextEditingController taxIdController,
File? avatarImage,
File? idCardFrontImage,
File? idCardBackImage,
List<File>? 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<String> 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<bool?> _showUnsavedChangesDialog(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Thay đổi chưa được lưu'),
content: const Text(
'Bạn có thay đổi chưa được lưu. Bạn có muốn thoát không?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Ở lại'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
child: const Text('Thoát'),
),
],
),
);
}
}