984 lines
34 KiB
Dart
984 lines
34 KiB
Dart
/// Registration Page
|
|
///
|
|
/// User registration form with role-based verification requirements.
|
|
/// Matches design from html/register.html
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
|
|
import 'package:worker/core/constants/ui_constants.dart';
|
|
import 'package:worker/core/router/app_router.dart';
|
|
import 'package:worker/core/theme/colors.dart';
|
|
import 'package:worker/core/utils/validators.dart';
|
|
import 'package:worker/features/auth/domain/entities/business_unit.dart';
|
|
import 'package:worker/features/auth/domain/entities/city.dart';
|
|
import 'package:worker/features/auth/domain/entities/customer_group.dart';
|
|
import 'package:worker/features/auth/presentation/providers/cities_provider.dart';
|
|
import 'package:worker/features/auth/presentation/providers/customer_groups_provider.dart';
|
|
import 'package:worker/features/auth/presentation/providers/session_provider.dart';
|
|
import 'package:worker/features/auth/presentation/widgets/phone_input_field.dart';
|
|
import 'package:worker/features/auth/presentation/widgets/file_upload_card.dart';
|
|
|
|
/// Registration Page
|
|
///
|
|
/// Features:
|
|
/// - Full name, phone, email, password fields
|
|
/// - Role selection (dealer/worker/broker/other)
|
|
/// - Conditional verification section for workers/dealers
|
|
/// - File upload for ID card and certificate
|
|
/// - Company name and city selection
|
|
/// - Terms and conditions checkbox
|
|
///
|
|
/// Navigation:
|
|
/// - From: Business unit selection page
|
|
/// - To: OTP verification (broker/other) or pending approval (worker/dealer)
|
|
class RegisterPage extends ConsumerStatefulWidget {
|
|
/// Selected business unit from previous screen
|
|
final BusinessUnit? selectedBusinessUnit;
|
|
|
|
const RegisterPage({super.key, this.selectedBusinessUnit});
|
|
|
|
@override
|
|
ConsumerState<RegisterPage> createState() => _RegisterPageState();
|
|
}
|
|
|
|
class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|
// Form key
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
// Text controllers
|
|
final _fullNameController = TextEditingController();
|
|
final _phoneController = TextEditingController();
|
|
final _emailController = TextEditingController();
|
|
final _passwordController = TextEditingController();
|
|
final _idNumberController = TextEditingController();
|
|
final _taxCodeController = TextEditingController();
|
|
final _companyController = TextEditingController();
|
|
|
|
// Focus nodes
|
|
final _fullNameFocus = FocusNode();
|
|
final _phoneFocus = FocusNode();
|
|
final _emailFocus = FocusNode();
|
|
final _passwordFocus = FocusNode();
|
|
final _idNumberFocus = FocusNode();
|
|
final _taxCodeFocus = FocusNode();
|
|
final _companyFocus = FocusNode();
|
|
|
|
// State
|
|
CustomerGroup? _selectedRole;
|
|
City? _selectedCity;
|
|
File? _idCardFile;
|
|
File? _certificateFile;
|
|
bool _termsAccepted = false;
|
|
bool _passwordVisible = false;
|
|
bool _isLoading = false;
|
|
bool _isLoadingData = true;
|
|
bool _hasInitialized = false;
|
|
|
|
final _imagePicker = ImagePicker();
|
|
|
|
/// Initialize session and load data
|
|
/// This should be called from build method or after widget is mounted
|
|
Future<void> _initializeData() async {
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_isLoadingData = true;
|
|
_hasInitialized = true;
|
|
});
|
|
|
|
try {
|
|
// Step 1: Get session (public API user)
|
|
await ref.read(sessionProvider.notifier).getSession();
|
|
|
|
// Step 2: Fetch cities and customer groups in parallel using the session
|
|
await Future.wait([
|
|
ref.read(citiesProvider.notifier).fetchCities(),
|
|
ref.read(customerGroupsProvider.notifier).fetchCustomerGroups(),
|
|
]);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Lỗi tải dữ liệu: $e'),
|
|
backgroundColor: AppColors.danger,
|
|
action: SnackBarAction(
|
|
label: 'Thử lại',
|
|
textColor: AppColors.white,
|
|
onPressed: _initializeData,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoadingData = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_fullNameController.dispose();
|
|
_phoneController.dispose();
|
|
_emailController.dispose();
|
|
_passwordController.dispose();
|
|
_idNumberController.dispose();
|
|
_taxCodeController.dispose();
|
|
_companyController.dispose();
|
|
_fullNameFocus.dispose();
|
|
_phoneFocus.dispose();
|
|
_emailFocus.dispose();
|
|
_passwordFocus.dispose();
|
|
_idNumberFocus.dispose();
|
|
_taxCodeFocus.dispose();
|
|
_companyFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Check if verification section should be shown
|
|
/// Note: This is based on the old role system
|
|
/// TODO: Update this logic based on actual customer group requirements
|
|
bool get _shouldShowVerification {
|
|
// For now, always hide verification section since we're using customer groups
|
|
return false;
|
|
}
|
|
|
|
/// 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) {
|
|
debugPrint('Error converting file to base64: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Pick image from gallery or camera
|
|
Future<void> _pickImage(bool isIdCard) async {
|
|
try {
|
|
// Show bottom sheet to select source
|
|
final source = await showModalBottomSheet<ImageSource>(
|
|
context: context,
|
|
builder: (context) => SafeArea(
|
|
child: Wrap(
|
|
children: [
|
|
ListTile(
|
|
leading: const FaIcon(FontAwesomeIcons.camera, size: 20),
|
|
title: const Text('Chụp ảnh'),
|
|
onTap: () => Navigator.pop(context, ImageSource.camera),
|
|
),
|
|
ListTile(
|
|
leading: const FaIcon(FontAwesomeIcons.images, size: 20),
|
|
title: const Text('Chọn từ thư viện'),
|
|
onTap: () => Navigator.pop(context, ImageSource.gallery),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
if (source == null) return;
|
|
|
|
final pickedFile = await _imagePicker.pickImage(
|
|
source: source,
|
|
maxWidth: 1920,
|
|
maxHeight: 1080,
|
|
imageQuality: 85,
|
|
);
|
|
|
|
if (pickedFile == null) return;
|
|
|
|
final file = File(pickedFile.path);
|
|
|
|
// Validate file size (max 5MB)
|
|
final fileSize = await file.length();
|
|
if (fileSize > 5 * 1024 * 1024) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('File không được vượt quá 5MB'),
|
|
backgroundColor: AppColors.danger,
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
if (isIdCard) {
|
|
_idCardFile = file;
|
|
} else {
|
|
_certificateFile = file;
|
|
}
|
|
});
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Lỗi chọn ảnh: $e'),
|
|
backgroundColor: AppColors.danger,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Remove selected image
|
|
void _removeImage(bool isIdCard) {
|
|
setState(() {
|
|
if (isIdCard) {
|
|
_idCardFile = null;
|
|
} else {
|
|
_certificateFile = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Validate form and submit
|
|
Future<void> _handleRegister() async {
|
|
// Validate form
|
|
if (!_formKey.currentState!.validate()) {
|
|
return;
|
|
}
|
|
|
|
// Check terms acceptance
|
|
if (!_termsAccepted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'Vui lòng đồng ý với Điều khoản sử dụng và Chính sách bảo mật',
|
|
),
|
|
backgroundColor: AppColors.warning,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Validate verification requirements for workers/dealers
|
|
if (_shouldShowVerification) {
|
|
if (_idNumberController.text.trim().isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Vui lòng nhập số CCCD/CMND'),
|
|
backgroundColor: AppColors.warning,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (_idCardFile == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Vui lòng tải lên ảnh CCCD/CMND'),
|
|
backgroundColor: AppColors.warning,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (_certificateFile == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Vui lòng tải lên ảnh chứng chỉ hành nghề hoặc GPKD'),
|
|
backgroundColor: AppColors.warning,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
try {
|
|
// Get session state for CSRF token and SID
|
|
final sessionState = ref.read(sessionProvider);
|
|
|
|
if (!sessionState.hasSession) {
|
|
throw Exception('Session không hợp lệ. Vui lòng thử lại.');
|
|
}
|
|
|
|
// Convert files to base64
|
|
final idCardFrontBase64 = await _fileToBase64(_idCardFile);
|
|
final certificatesBase64 = _certificateFile != null
|
|
? [await _fileToBase64(_certificateFile)]
|
|
: <String?>[];
|
|
|
|
// Remove null values from certificates list
|
|
final validCertificates = certificatesBase64
|
|
.whereType<String>()
|
|
.toList();
|
|
|
|
// Call registration API
|
|
final dataSource = ref.read(authRemoteDataSourceProvider);
|
|
final response = await dataSource.register(
|
|
csrfToken: sessionState.csrfToken!,
|
|
sid: sessionState.sid!,
|
|
fullName: _fullNameController.text.trim(),
|
|
phone: _phoneController.text.trim(),
|
|
email: _emailController.text.trim(),
|
|
customerGroupCode: _selectedRole?.value ?? _selectedRole?.name ?? '',
|
|
cityCode: _selectedCity?.code ?? '',
|
|
companyName: _companyController.text.trim().isEmpty
|
|
? null
|
|
: _companyController.text.trim(),
|
|
taxCode: _taxCodeController.text.trim().isEmpty
|
|
? null
|
|
: _taxCodeController.text.trim(),
|
|
idCardFrontBase64: idCardFrontBase64,
|
|
idCardBackBase64: null, // Not collecting back side in current UI
|
|
certificatesBase64: validCertificates,
|
|
);
|
|
|
|
if (mounted) {
|
|
// Show success message
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'Đăng ký thành công!',
|
|
// response['message']?.toString() ?? 'Đăng ký thành công!',
|
|
),
|
|
duration: Duration(seconds: 1),
|
|
backgroundColor: AppColors.success,
|
|
),
|
|
);
|
|
|
|
Future<void>.delayed(const Duration(seconds: 1)).then((_) => context.goLogin());
|
|
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Đăng ký thất bại: $e'),
|
|
backgroundColor: AppColors.danger,
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Initialize data on first build
|
|
if (!_hasInitialized) {
|
|
// Use addPostFrameCallback to avoid calling setState during build
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_initializeData();
|
|
});
|
|
}
|
|
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFFF4F6F8),
|
|
appBar: AppBar(
|
|
backgroundColor: AppColors.white,
|
|
elevation: AppBarSpecs.elevation,
|
|
leading: IconButton(
|
|
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
|
|
onPressed: () => context.pop(),
|
|
),
|
|
title: const Text(
|
|
'Đăng ký tài khoản',
|
|
style: TextStyle(
|
|
color: Colors.black,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
centerTitle: false,
|
|
),
|
|
body: _isLoadingData
|
|
? const Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CircularProgressIndicator(),
|
|
SizedBox(height: AppSpacing.md),
|
|
Text(
|
|
'Đang tải dữ liệu...',
|
|
style: TextStyle(color: AppColors.grey500),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: SafeArea(
|
|
child: Form(
|
|
key: _formKey,
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Welcome section
|
|
const Text(
|
|
'Tạo tài khoản mới',
|
|
style: TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.grey900,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: AppSpacing.xs),
|
|
const Text(
|
|
'Điền thông tin để bắt đầu',
|
|
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
|
|
// Form card
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.white,
|
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.05),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Full Name
|
|
_buildLabel('Họ và tên *'),
|
|
TextFormField(
|
|
controller: _fullNameController,
|
|
focusNode: _fullNameFocus,
|
|
textInputAction: TextInputAction.next,
|
|
decoration: _buildInputDecoration(
|
|
hintText: 'Nhập họ và tên',
|
|
prefixIcon: FontAwesomeIcons.user,
|
|
),
|
|
validator: (value) => Validators.minLength(
|
|
value,
|
|
3,
|
|
fieldName: 'Họ và tên',
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
// Phone Number
|
|
_buildLabel('Số điện thoại *'),
|
|
PhoneInputField(
|
|
controller: _phoneController,
|
|
focusNode: _phoneFocus,
|
|
validator: Validators.phone,
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
// Email
|
|
_buildLabel('Email *'),
|
|
TextFormField(
|
|
controller: _emailController,
|
|
focusNode: _emailFocus,
|
|
keyboardType: TextInputType.emailAddress,
|
|
textInputAction: TextInputAction.next,
|
|
decoration: _buildInputDecoration(
|
|
hintText: 'Nhập email',
|
|
prefixIcon: FontAwesomeIcons.envelope,
|
|
),
|
|
validator: Validators.email,
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
// Password
|
|
_buildLabel('Mật khẩu *'),
|
|
TextFormField(
|
|
controller: _passwordController,
|
|
focusNode: _passwordFocus,
|
|
obscureText: !_passwordVisible,
|
|
textInputAction: TextInputAction.done,
|
|
decoration: _buildInputDecoration(
|
|
hintText: 'Tạo mật khẩu mới',
|
|
prefixIcon: FontAwesomeIcons.lock,
|
|
suffixIcon: IconButton(
|
|
icon: Icon(
|
|
_passwordVisible
|
|
? FontAwesomeIcons.eye
|
|
: FontAwesomeIcons.eyeSlash,
|
|
color: AppColors.grey500,
|
|
),
|
|
onPressed: () {
|
|
setState(() {
|
|
_passwordVisible = !_passwordVisible;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
validator: (value) =>
|
|
Validators.passwordSimple(value, minLength: 6),
|
|
),
|
|
const SizedBox(height: AppSpacing.xs),
|
|
const Text(
|
|
'Mật khẩu tối thiểu 6 ký tự',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: AppColors.grey500,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
// Role Selection (Customer Groups)
|
|
_buildLabel('Vai trò *'),
|
|
_buildCustomerGroupDropdown(),
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
// Verification Section (conditional)
|
|
if (_shouldShowVerification) ...[
|
|
_buildVerificationSection(),
|
|
const SizedBox(height: AppSpacing.md),
|
|
],
|
|
|
|
// Company Name (optional)
|
|
_buildLabel('Tên công ty/Cửa hàng'),
|
|
TextFormField(
|
|
controller: _companyController,
|
|
focusNode: _companyFocus,
|
|
textInputAction: TextInputAction.next,
|
|
decoration: _buildInputDecoration(
|
|
hintText: 'Nhập tên công ty (không bắt buộc)',
|
|
prefixIcon: FontAwesomeIcons.building,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
// City/Province
|
|
_buildLabel('Tỉnh/Thành phố *'),
|
|
_buildCityDropdown(),
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
// Terms and Conditions
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Checkbox(
|
|
value: _termsAccepted,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_termsAccepted = value ?? false;
|
|
});
|
|
},
|
|
activeColor: AppColors.primaryBlue,
|
|
),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 12.0),
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
setState(() {
|
|
_termsAccepted = !_termsAccepted;
|
|
});
|
|
},
|
|
child: const Text.rich(
|
|
TextSpan(
|
|
text: 'Tôi đồng ý với ',
|
|
style: TextStyle(fontSize: 13),
|
|
children: [
|
|
TextSpan(
|
|
text: 'Điều khoản sử dụng',
|
|
style: TextStyle(
|
|
color: AppColors.primaryBlue,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
TextSpan(text: ' và '),
|
|
TextSpan(
|
|
text: 'Chính sách bảo mật',
|
|
style: TextStyle(
|
|
color: AppColors.primaryBlue,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
|
|
// Register Button
|
|
SizedBox(
|
|
height: ButtonSpecs.height,
|
|
child: ElevatedButton(
|
|
onPressed: _isLoading ? null : _handleRegister,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primaryBlue,
|
|
foregroundColor: AppColors.white,
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(
|
|
ButtonSpecs.borderRadius,
|
|
),
|
|
),
|
|
),
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
AppColors.white,
|
|
),
|
|
),
|
|
)
|
|
: const Text(
|
|
'Đăng ký',
|
|
style: TextStyle(
|
|
fontSize: ButtonSpecs.fontSize,
|
|
fontWeight: ButtonSpecs.fontWeight,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
|
|
// Login Link
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Text(
|
|
'Đã có tài khoản? ',
|
|
style: TextStyle(fontSize: 13, color: AppColors.grey500),
|
|
),
|
|
GestureDetector(
|
|
onTap: () => context.pop(),
|
|
child: const Text(
|
|
'Đăng nhập',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: AppColors.primaryBlue,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Build label widget
|
|
Widget _buildLabel(String text) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
|
child: Text(
|
|
text,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.grey900,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Build input decoration
|
|
InputDecoration _buildInputDecoration({
|
|
required String hintText,
|
|
required IconData prefixIcon,
|
|
Widget? suffixIcon,
|
|
}) {
|
|
return InputDecoration(
|
|
hintText: hintText,
|
|
hintStyle: const TextStyle(
|
|
fontSize: InputFieldSpecs.hintFontSize,
|
|
color: AppColors.grey500,
|
|
),
|
|
prefixIcon: Icon(
|
|
prefixIcon,
|
|
color: AppColors.primaryBlue,
|
|
size: AppIconSize.md,
|
|
),
|
|
suffixIcon: suffixIcon,
|
|
filled: true,
|
|
fillColor: AppColors.white,
|
|
contentPadding: InputFieldSpecs.contentPadding,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
|
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
|
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
|
borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2.0),
|
|
),
|
|
errorBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
|
borderSide: const BorderSide(color: AppColors.danger, width: 1.0),
|
|
),
|
|
focusedErrorBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
|
borderSide: const BorderSide(color: AppColors.danger, width: 2.0),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Build customer group dropdown
|
|
Widget _buildCustomerGroupDropdown() {
|
|
final customerGroupsAsync = ref.watch(customerGroupsProvider);
|
|
|
|
return customerGroupsAsync.when(
|
|
data: (groups) {
|
|
return DropdownButtonFormField<CustomerGroup>(
|
|
initialValue: _selectedRole,
|
|
decoration: _buildInputDecoration(
|
|
hintText: 'Chọn vai trò',
|
|
prefixIcon: FontAwesomeIcons.briefcase,
|
|
),
|
|
items: groups
|
|
.map(
|
|
(group) => DropdownMenuItem(
|
|
value: group,
|
|
child: Text(group.customerGroupName),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_selectedRole = value;
|
|
// Clear verification fields when role changes
|
|
if (!_shouldShowVerification) {
|
|
_idNumberController.clear();
|
|
_taxCodeController.clear();
|
|
_idCardFile = null;
|
|
_certificateFile = null;
|
|
}
|
|
});
|
|
},
|
|
validator: (value) {
|
|
if (value == null) {
|
|
return 'Vui lòng chọn vai trò';
|
|
}
|
|
return null;
|
|
},
|
|
);
|
|
},
|
|
loading: () => const SizedBox(
|
|
height: 48,
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
error: (error, stack) => Container(
|
|
height: 48,
|
|
padding: const EdgeInsets.all(AppSpacing.sm),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.danger.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
|
border: Border.all(color: AppColors.danger),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.error_outline, color: AppColors.danger, size: 20),
|
|
const SizedBox(width: AppSpacing.xs),
|
|
Expanded(
|
|
child: Text(
|
|
'Lỗi tải vai trò',
|
|
style: const TextStyle(color: AppColors.danger, fontSize: 12),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: _initializeData,
|
|
child: const Text('Thử lại', style: TextStyle(fontSize: 12)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Build city dropdown
|
|
Widget _buildCityDropdown() {
|
|
final citiesAsync = ref.watch(citiesProvider);
|
|
|
|
return citiesAsync.when(
|
|
data: (cities) {
|
|
return DropdownButtonFormField<City>(
|
|
initialValue: _selectedCity,
|
|
decoration: _buildInputDecoration(
|
|
hintText: 'Chọn tỉnh/thành phố',
|
|
prefixIcon: Icons.location_city,
|
|
),
|
|
items: cities
|
|
.map(
|
|
(city) => DropdownMenuItem(
|
|
value: city,
|
|
child: Text(city.cityName),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_selectedCity = value;
|
|
});
|
|
},
|
|
validator: (value) {
|
|
if (value == null) {
|
|
return 'Vui lòng chọn tỉnh/thành phố';
|
|
}
|
|
return null;
|
|
},
|
|
);
|
|
},
|
|
loading: () => const SizedBox(
|
|
height: 48,
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
error: (error, stack) => Container(
|
|
height: 48,
|
|
padding: const EdgeInsets.all(AppSpacing.sm),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.danger.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
|
border: Border.all(color: AppColors.danger),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.error_outline, color: AppColors.danger, size: 20),
|
|
const SizedBox(width: AppSpacing.xs),
|
|
Expanded(
|
|
child: Text(
|
|
'Lỗi tải danh sách tỉnh/thành phố',
|
|
style: const TextStyle(color: AppColors.danger, fontSize: 12),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: _initializeData,
|
|
child: const Text('Thử lại', style: TextStyle(fontSize: 12)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Build verification section
|
|
Widget _buildVerificationSection() {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF8FAFC),
|
|
border: Border.all(color: const Color(0xFFE2E8F0), width: 2),
|
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|
),
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Header
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.shield, color: AppColors.primaryBlue, size: 20),
|
|
const SizedBox(width: AppSpacing.xs),
|
|
const Text(
|
|
'Thông tin xác thực',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.primaryBlue,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.xs),
|
|
const Text(
|
|
'Thông tin này sẽ được dùng để xác minh tư cách chuyên môn của bạn',
|
|
style: TextStyle(fontSize: 12, color: AppColors.grey500),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
// ID Number
|
|
_buildLabel('Số CCCD/CMND'),
|
|
TextFormField(
|
|
controller: _idNumberController,
|
|
focusNode: _idNumberFocus,
|
|
keyboardType: TextInputType.number,
|
|
textInputAction: TextInputAction.next,
|
|
decoration: _buildInputDecoration(
|
|
hintText: 'Nhập số CCCD/CMND',
|
|
prefixIcon: Icons.badge,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
// Tax Code
|
|
_buildLabel('Mã số thuế cá nhân/Công ty'),
|
|
TextFormField(
|
|
controller: _taxCodeController,
|
|
focusNode: _taxCodeFocus,
|
|
keyboardType: TextInputType.number,
|
|
textInputAction: TextInputAction.done,
|
|
decoration: _buildInputDecoration(
|
|
hintText: 'Nhập mã số thuế (không bắt buộc)',
|
|
prefixIcon: Icons.receipt_long,
|
|
),
|
|
validator: Validators.taxIdOptional,
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
// ID Card Upload
|
|
_buildLabel('Ảnh mặt trước CCCD/CMND'),
|
|
FileUploadCard(
|
|
file: _idCardFile,
|
|
onTap: () => _pickImage(true),
|
|
onRemove: () => _removeImage(true),
|
|
icon: Icons.camera_alt,
|
|
title: 'Chụp ảnh hoặc chọn file',
|
|
subtitle: 'JPG, PNG tối đa 5MB',
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
|
|
// Certificate Upload
|
|
_buildLabel('Ảnh chứng chỉ hành nghề hoặc GPKD'),
|
|
FileUploadCard(
|
|
file: _certificateFile,
|
|
onTap: () => _pickImage(false),
|
|
onRemove: () => _removeImage(false),
|
|
icon: Icons.file_present,
|
|
title: 'Chụp ảnh hoặc chọn file',
|
|
subtitle: 'JPG, PNG tối đa 5MB',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|