1877 lines
65 KiB
Dart
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'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|