add profile edit

This commit is contained in:
Phuoc Nguyen
2025-11-03 15:17:45 +07:00
parent aa3a52bba7
commit d8e3ca4c46
3 changed files with 710 additions and 24 deletions

View File

@@ -23,6 +23,7 @@ import 'package:worker/features/quotes/presentation/pages/quotes_page.dart';
import 'package:worker/features/price_policy/price_policy.dart'; import 'package:worker/features/price_policy/price_policy.dart';
import 'package:worker/features/news/presentation/pages/news_list_page.dart'; import 'package:worker/features/news/presentation/pages/news_list_page.dart';
import 'package:worker/features/news/presentation/pages/news_detail_page.dart'; import 'package:worker/features/news/presentation/pages/news_detail_page.dart';
import 'package:worker/features/account/presentation/pages/profile_edit_page.dart';
/// App Router /// App Router
/// ///
@@ -201,6 +202,14 @@ class AppRouter {
}, },
), ),
// Profile Edit Route
GoRoute(
path: RouteNames.profile,
name: RouteNames.profile,
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const ProfileEditPage()),
),
// TODO: Add more routes as features are implemented // TODO: Add more routes as features are implemented
], ],

View File

@@ -33,19 +33,15 @@ class AccountPage extends StatelessWidget {
// Simple Header // Simple Header
_buildHeader(), _buildHeader(),
// User Profile Card // User Profile Card
_buildProfileCard(context), _buildProfileCard(context),
// Account Menu Section // Account Menu Section
_buildAccountMenu(context), _buildAccountMenu(context),
// Support Section // Support Section
_buildSupportSection(context), _buildSupportSection(context),
// Logout Button // Logout Button
_buildLogoutButton(context), _buildLogoutButton(context),
@@ -142,18 +138,12 @@ class AccountPage extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
const Text( const Text(
'Kiến trúc sư · Hạng Diamond', 'Kiến trúc sư · Hạng Diamond',
style: TextStyle( style: TextStyle(fontSize: 13, color: AppColors.grey500),
fontSize: 13,
color: AppColors.grey500,
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
const Text( const Text(
'0983 441 099', '0983 441 099',
style: TextStyle( style: TextStyle(fontSize: 13, color: AppColors.primaryBlue),
fontSize: 13,
color: AppColors.primaryBlue,
),
), ),
], ],
), ),
@@ -185,7 +175,7 @@ class AccountPage extends StatelessWidget {
title: 'Thông tin cá nhân', title: 'Thông tin cá nhân',
subtitle: 'Cập nhật thông tin tài khoản', subtitle: 'Cập nhật thông tin tài khoản',
onTap: () { onTap: () {
_showComingSoon(context); context.push(RouteNames.profile);
}, },
), ),
AccountMenuItem( AccountMenuItem(
@@ -353,20 +343,14 @@ class AccountPage extends StatelessWidget {
children: [ children: [
const Text( const Text(
'EuroTile & Vasta Stone Worker', 'EuroTile & Vasta Stone Worker',
style: TextStyle( style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
fontSize: 16,
fontWeight: FontWeight.w600,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text('Phiên bản: 1.0.0'), const Text('Phiên bản: 1.0.0'),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Ứng dụng dành cho thầu thợ, kiến trúc sư, đại lý và môi giới trong ngành gạch ốp lát và nội thất.', 'Ứng dụng dành cho thầu thợ, kiến trúc sư, đại lý và môi giới trong ngành gạch ốp lát và nội thất.',
style: TextStyle( style: TextStyle(fontSize: 14, color: AppColors.grey500),
fontSize: 14,
color: AppColors.grey500,
),
), ),
], ],
), ),
@@ -402,9 +386,7 @@ class AccountPage extends StatelessWidget {
), ),
); );
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(foregroundColor: AppColors.danger),
foregroundColor: AppColors.danger,
),
child: const Text('Đăng xuất'), child: const Text('Đăng xuất'),
), ),
], ],

View File

@@ -0,0 +1,695 @@
/// 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:io';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
/// 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) {
// Form key for validation
final formKey = useMemoized(() => GlobalKey<FormState>());
// Image picker
final selectedImage = useState<File?>(null);
// Form controllers
final nameController = useTextEditingController(text: 'Hoàng Minh Hiệp');
final phoneController = useTextEditingController(text: '0347302911');
final emailController = useTextEditingController(
text: 'hoanghiep@example.com',
);
final birthDateController = useTextEditingController(text: '15/03/1985');
final idNumberController = useTextEditingController(text: '123456789012');
final taxIdController = useTextEditingController(text: '0359837618');
final companyController = useTextEditingController(
text: 'Công ty TNHH Xây dựng ABC',
);
final addressController = useTextEditingController(
text: '123 Man Thiện, Thủ Đức, Hồ Chí Minh',
);
final experienceController = useTextEditingController(text: '10');
// Dropdown values
final selectedGender = useState<String>('male');
final selectedPosition = useState<String>('contractor');
// Has unsaved changes
final hasChanges = useState<bool>(false);
return PopScope(
canPop: !hasChanges.value,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _showUnsavedChangesDialog(context);
if (shouldPop == true && context.mounted) {
Navigator.of(context).pop();
}
},
child: Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black),
onPressed: () async {
if (hasChanges.value) {
final shouldPop = await _showUnsavedChangesDialog(context);
if (shouldPop == true && context.mounted) {
context.pop();
}
} else {
context.pop();
}
},
),
title: const Text(
'Thông tin cá nhân',
style: TextStyle(
color: Colors.black,
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
_buildAvatarSection(context, selectedImage),
const SizedBox(height: AppSpacing.md),
// Form Card
Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Full Name
_buildTextField(
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
_buildTextField(
label: 'Số điện thoại',
controller: phoneController,
required: true,
keyboardType: TextInputType.phone,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vui lòng nhập số điện thoại';
}
return null;
},
),
const SizedBox(height: AppSpacing.md),
// Email
_buildTextField(
label: 'Email',
controller: emailController,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: AppSpacing.md),
// Birth Date
_buildDateField(
context: context,
label: 'Ngày sinh',
controller: birthDateController,
),
const SizedBox(height: AppSpacing.md),
// Gender
_buildDropdownField(
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),
// ID Number
_buildTextField(
label: 'Số CMND/CCCD',
controller: idNumberController,
keyboardType: TextInputType.number,
),
const SizedBox(height: AppSpacing.md),
// Tax ID
_buildTextField(
label: 'Mã số thuế',
controller: taxIdController,
),
const SizedBox(height: AppSpacing.md),
// Company
_buildTextField(
label: 'Công ty',
controller: companyController,
),
const SizedBox(height: AppSpacing.md),
// Address
_buildTextField(
label: 'Địa chỉ',
controller: addressController,
maxLines: 2,
),
const SizedBox(height: AppSpacing.md),
// Position
_buildDropdownField(
label: 'Chức vụ',
value: selectedPosition.value,
items: const [
{'value': 'contractor', 'label': 'Thầu thợ'},
{'value': 'architect', 'label': 'Kiến trúc sư'},
{'value': 'dealer', 'label': 'Đại lý phân phối'},
{'value': 'broker', 'label': 'Môi giới'},
{'value': 'other', 'label': 'Khác'},
],
onChanged: (value) {
if (value != null) {
selectedPosition.value = value;
hasChanges.value = true;
}
},
),
const SizedBox(height: AppSpacing.md),
// Experience
_buildTextField(
label: 'Kinh nghiệm (năm)',
controller: experienceController,
keyboardType: TextInputType.number,
),
],
),
),
const SizedBox(height: AppSpacing.lg),
// Action Buttons
_buildActionButtons(
context: context,
formKey: formKey,
hasChanges: hasChanges,
),
const SizedBox(height: AppSpacing.lg),
],
),
),
),
),
);
}
/// Build avatar section with edit button
Widget _buildAvatarSection(
BuildContext context,
ValueNotifier<File?> selectedImage,
) {
return Center(
child: Stack(
children: [
// Avatar
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primaryBlue,
image: selectedImage.value != null
? DecorationImage(
image: FileImage(selectedImage.value!),
fit: BoxFit.cover,
)
: null,
),
child: selectedImage.value == null
? const Center(
child: Text(
'HMH',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
)
: null,
),
// Edit Button
Positioned(
bottom: 0,
right: 0,
child: GestureDetector(
onTap: () async {
await _pickImage(context, selectedImage);
},
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: AppColors.primaryBlue,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.camera_alt,
size: 16,
color: Colors.white,
),
),
),
),
],
),
);
}
/// Build text field
Widget _buildTextField({
required String label,
required TextEditingController controller,
bool required = false,
TextInputType? keyboardType,
int maxLines = 1,
String? Function(String?)? validator,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
),
children: [
if (required)
const TextSpan(
text: ' *',
style: TextStyle(color: AppColors.danger),
),
],
),
),
const SizedBox(height: 8),
TextFormField(
controller: controller,
keyboardType: keyboardType,
maxLines: maxLines,
validator: validator,
decoration: InputDecoration(
hintText: 'Nhập $label',
hintStyle: TextStyle(
color: AppColors.grey500.withValues(alpha: 0.6),
fontSize: 14,
),
filled: true,
fillColor: const Color(0xFFF8FAFC),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
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 String label,
required TextEditingController controller,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
),
),
const SizedBox(height: 8),
TextFormField(
controller: controller,
readOnly: true,
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: DateTime(1985, 3, 15),
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}';
}
},
decoration: InputDecoration(
hintText: 'Chọn ngày sinh',
hintStyle: TextStyle(
color: AppColors.grey500.withValues(alpha: 0.6),
fontSize: 14,
),
filled: true,
fillColor: const Color(0xFFF8FAFC),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
suffixIcon: const Icon(Icons.calendar_today, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
width: 2,
),
),
),
),
],
);
}
/// Build dropdown field
Widget _buildDropdownField({
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: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: value,
onChanged: onChanged,
decoration: InputDecoration(
filled: true,
fillColor: const Color(0xFFF8FAFC),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
width: 2,
),
),
),
items: items.map((item) {
return DropdownMenuItem<String>(
value: item['value'],
child: Text(item['label']!, style: const TextStyle(fontSize: 14)),
);
}).toList(),
),
],
);
}
/// Build action buttons
Widget _buildActionButtons({
required BuildContext context,
required GlobalKey<FormState> formKey,
required ValueNotifier<bool> hasChanges,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Row(
children: [
// Cancel Button
Expanded(
child: OutlinedButton(
onPressed: () {
context.pop();
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
side: const BorderSide(color: AppColors.grey100),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
),
),
child: const Text(
'Hủy bỏ',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
),
),
const SizedBox(width: AppSpacing.sm),
// Save Button
Expanded(
flex: 2,
child: ElevatedButton.icon(
onPressed: () {
if (formKey.currentState?.validate() ?? false) {
// TODO: Save profile data
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Thông tin đã được cập nhật thành công!'),
backgroundColor: AppColors.success,
duration: Duration(seconds: 2),
),
);
hasChanges.value = false;
context.pop();
}
},
icon: const Icon(Icons.save, size: 20),
label: const Text(
'Lưu thay đổi',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
),
),
),
),
],
),
);
}
/// Pick image from gallery or camera
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 Icon(Icons.camera_alt),
title: const Text('Máy ảnh'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library),
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: 512,
maxHeight: 512,
imageQuality: 85,
);
if (image != null) {
selectedImage.value = File(image.path);
}
}
}
/// 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'),
),
],
),
);
}
}