diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index c9aaebe..88b0e03 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -7,6 +7,7 @@ library; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:worker/features/cart/presentation/pages/cart_page.dart'; +import 'package:worker/features/cart/presentation/pages/checkout_page.dart'; import 'package:worker/features/favorites/presentation/pages/favorites_page.dart'; import 'package:worker/features/loyalty/presentation/pages/loyalty_page.dart'; import 'package:worker/features/loyalty/presentation/pages/points_history_page.dart'; @@ -24,6 +25,8 @@ 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_detail_page.dart'; import 'package:worker/features/account/presentation/pages/profile_edit_page.dart'; +import 'package:worker/features/account/presentation/pages/addresses_page.dart'; +import 'package:worker/features/account/presentation/pages/change_password_page.dart'; /// App Router /// @@ -91,6 +94,14 @@ class AppRouter { MaterialPage(key: state.pageKey, child: const CartPage()), ), + // Checkout Route + GoRoute( + path: RouteNames.checkout, + name: RouteNames.checkout, + pageBuilder: (context, state) => + MaterialPage(key: state.pageKey, child: const CheckoutPage()), + ), + // Favorites Route GoRoute( path: RouteNames.favorites, @@ -210,6 +221,22 @@ class AppRouter { MaterialPage(key: state.pageKey, child: const ProfileEditPage()), ), + // Addresses Route + GoRoute( + path: RouteNames.addresses, + name: RouteNames.addresses, + pageBuilder: (context, state) => + MaterialPage(key: state.pageKey, child: const AddressesPage()), + ), + + // Change Password Route + GoRoute( + path: RouteNames.changePassword, + name: RouteNames.changePassword, + pageBuilder: (context, state) => + MaterialPage(key: state.pageKey, child: const ChangePasswordPage()), + ), + // TODO: Add more routes as features are implemented ], diff --git a/lib/features/account/presentation/pages/account_page.dart b/lib/features/account/presentation/pages/account_page.dart index 78a6115..6034ed0 100644 --- a/lib/features/account/presentation/pages/account_page.dart +++ b/lib/features/account/presentation/pages/account_page.dart @@ -191,7 +191,7 @@ class AccountPage extends StatelessWidget { title: 'Địa chỉ đã lưu', subtitle: 'Quản lý địa chỉ giao hàng', onTap: () { - _showComingSoon(context); + context.push(RouteNames.addresses); }, ), AccountMenuItem( @@ -207,7 +207,7 @@ class AccountPage extends StatelessWidget { title: 'Đổi mật khẩu', subtitle: 'Cập nhật mật khẩu mới', onTap: () { - _showComingSoon(context); + context.push(RouteNames.changePassword); }, ), AccountMenuItem( diff --git a/lib/features/account/presentation/pages/addresses_page.dart b/lib/features/account/presentation/pages/addresses_page.dart new file mode 100644 index 0000000..ccc7bad --- /dev/null +++ b/lib/features/account/presentation/pages/addresses_page.dart @@ -0,0 +1,282 @@ +/// Addresses Page +/// +/// Displays list of saved addresses with management options. +/// Features: +/// - List of saved addresses +/// - Default address indicator +/// - Edit/delete actions +/// - Set as default functionality +/// - Add new address +library; + +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:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/account/presentation/widgets/address_card.dart'; + +/// Addresses Page +/// +/// Page for managing saved delivery addresses. +class AddressesPage extends HookConsumerWidget { + const AddressesPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Mock addresses data + final addresses = useState>>([ + { + 'id': '1', + 'name': 'Hoàng Minh Hiệp', + 'phone': '0347302911', + 'address': + '123 Đường Võ Văn Ngân, Phường Linh Chiểu, Thành phố Thủ Đức, TP.HCM', + 'isDefault': true, + }, + { + 'id': '2', + 'name': 'Hoàng Minh Hiệp', + 'phone': '0347302911', + 'address': '456 Đường Nguyễn Thị Minh Khai, Quận 3, TP.HCM', + 'isDefault': false, + }, + { + 'id': '3', + 'name': 'Công ty TNHH ABC', + 'phone': '0283445566', + 'address': '789 Đường Lê Văn Sỹ, Quận Phú Nhuận, TP.HCM', + 'isDefault': false, + }, + ]); + + return Scaffold( + backgroundColor: const Color(0xFFF4F6F8), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => context.pop(), + ), + title: const Text( + 'Địa chỉ đã lưu', + style: TextStyle( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: false, + actions: [ + IconButton( + icon: const Icon(Icons.add, color: Colors.black), + onPressed: () { + _showAddAddress(context); + }, + ), + const SizedBox(width: AppSpacing.sm), + ], + ), + body: Column( + children: [ + // Address List + Expanded( + child: addresses.value.isEmpty + ? _buildEmptyState(context) + : ListView.separated( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: addresses.value.length, + separatorBuilder: (context, index) => + const SizedBox(height: AppSpacing.md), + itemBuilder: (context, index) { + final address = addresses.value[index]; + return AddressCard( + name: address['name'] as String, + phone: address['phone'] as String, + address: address['address'] as String, + isDefault: address['isDefault'] as bool, + onEdit: () { + _showEditAddress(context, address); + }, + onDelete: () { + _showDeleteConfirmation(context, addresses, index); + }, + onSetDefault: () { + _setDefaultAddress(addresses, index); + }, + ); + }, + ), + ), + + // Add New Address Button + Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + _showAddAddress(context); + }, + icon: const Icon(Icons.add, size: 20), + label: const Text( + 'Thêm địa chỉ mớ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), + ), + ), + ), + ), + ), + ], + ), + ); + } + + /// Build empty state + Widget _buildEmptyState(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.location_off, + size: 64, + color: AppColors.grey500.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + const Text( + 'Chưa có địa chỉ nào', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.grey500, + ), + ), + const SizedBox(height: 8), + const Text( + 'Thêm địa chỉ để nhận hàng nhanh hơn', + style: TextStyle(fontSize: 14, color: AppColors.grey500), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () { + _showAddAddress(context); + }, + icon: const Icon(Icons.add, size: 20), + label: const Text( + 'Thêm địa chỉ mới', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + ), + ], + ), + ); + } + + /// Set address as default + void _setDefaultAddress( + ValueNotifier>> addresses, + int index, + ) { + final updatedAddresses = addresses.value.map((address) { + return {...address, 'isDefault': false}; + }).toList(); + + updatedAddresses[index]['isDefault'] = true; + addresses.value = updatedAddresses; + } + + /// Show add address dialog (TODO: implement form page) + void _showAddAddress(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Chức năng thêm địa chỉ mới sẽ được phát triển'), + duration: Duration(seconds: 2), + ), + ); + } + + /// Show edit address dialog (TODO: implement form page) + void _showEditAddress(BuildContext context, Map address) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Chỉnh sửa địa chỉ: ${address['name']}'), + duration: const Duration(seconds: 2), + ), + ); + } + + /// Show delete confirmation dialog + void _showDeleteConfirmation( + BuildContext context, + ValueNotifier>> addresses, + int index, + ) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Xóa địa chỉ'), + content: const Text('Bạn có chắc chắn muốn xóa địa chỉ này?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Hủy'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _deleteAddress(context, addresses, index); + }, + style: TextButton.styleFrom(foregroundColor: AppColors.danger), + child: const Text('Xóa'), + ), + ], + ), + ); + } + + /// Delete address + void _deleteAddress( + BuildContext context, + ValueNotifier>> addresses, + int index, + ) { + final deletedAddress = addresses.value[index]; + final updatedAddresses = List>.from(addresses.value); + updatedAddresses.removeAt(index); + + // If deleted address was default and there are other addresses, + // set the first one as default + if (deletedAddress['isDefault'] == true && updatedAddresses.isNotEmpty) { + updatedAddresses[0]['isDefault'] = true; + } + + addresses.value = updatedAddresses; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Đã xóa địa chỉ'), + duration: Duration(seconds: 2), + ), + ); + } +} diff --git a/lib/features/account/presentation/pages/change_password_page.dart b/lib/features/account/presentation/pages/change_password_page.dart new file mode 100644 index 0000000..09a5938 --- /dev/null +++ b/lib/features/account/presentation/pages/change_password_page.dart @@ -0,0 +1,479 @@ +/// Change Password Page +/// +/// Allows users to change their password. +/// Features: +/// - Current password verification +/// - New password with validation +/// - Password confirmation +/// - Show/hide password toggles +/// - Security tips +library; + +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:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; + +/// Change Password Page +/// +/// Page for changing user password with validation and security tips. +class ChangePasswordPage extends HookConsumerWidget { + const ChangePasswordPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Form key for validation + final formKey = useMemoized(() => GlobalKey()); + + // Password controllers + final currentPasswordController = useTextEditingController(); + final newPasswordController = useTextEditingController(); + final confirmPasswordController = useTextEditingController(); + + // Password visibility states + final currentPasswordVisible = useState(false); + final newPasswordVisible = useState(false); + final confirmPasswordVisible = useState(false); + + // Password match state + final passwordsMatch = useState(null); + + // Listen to password changes for validation + useEffect(() { + void listener() { + final newPass = newPasswordController.text; + final confirmPass = confirmPasswordController.text; + + if (confirmPass.isEmpty) { + passwordsMatch.value = null; + } else { + passwordsMatch.value = newPass == confirmPass; + } + } + + newPasswordController.addListener(listener); + confirmPasswordController.addListener(listener); + + return () { + newPasswordController.removeListener(listener); + confirmPasswordController.removeListener(listener); + }; + }, []); + + return Scaffold( + backgroundColor: const Color(0xFFF4F6F8), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => context.pop(), + ), + title: const Text( + 'Thay đổi mật khẩu', + style: TextStyle( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: false, + actions: const [SizedBox(width: AppSpacing.sm)], + ), + body: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + children: [ + 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: [ + // Title + const Text( + 'Cập nhật mật khẩu', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF212121), + ), + ), + + const SizedBox(height: AppSpacing.lg), + + // Current Password + _buildPasswordField( + label: 'Mật khẩu hiện tại', + controller: currentPasswordController, + isVisible: currentPasswordVisible, + required: true, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Vui lòng nhập mật khẩu hiện tại'; + } + return null; + }, + ), + + const SizedBox(height: AppSpacing.md), + + // New Password + _buildPasswordField( + label: 'Mật khẩu mới', + controller: newPasswordController, + isVisible: newPasswordVisible, + required: true, + helpText: 'Mật khẩu phải có ít nhất 6 ký tự', + validator: (value) { + if (value == null || value.isEmpty) { + return 'Vui lòng nhập mật khẩu mới'; + } + if (value.length < 6) { + return 'Mật khẩu phải có ít nhất 6 ký tự'; + } + return null; + }, + ), + + const SizedBox(height: AppSpacing.md), + + // Confirm Password + _buildPasswordField( + label: 'Nhập lại mật khẩu mới', + controller: confirmPasswordController, + isVisible: confirmPasswordVisible, + required: true, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Vui lòng nhập lại mật khẩu mới'; + } + if (value != newPasswordController.text) { + return 'Mật khẩu không khớp'; + } + return null; + }, + ), + + // Password match indicator + if (passwordsMatch.value != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + passwordsMatch.value == true + ? Icons.check_circle + : Icons.error, + size: 16, + color: passwordsMatch.value == true + ? AppColors.success + : AppColors.danger, + ), + const SizedBox(width: 8), + Text( + passwordsMatch.value == true + ? 'Mật khẩu khớp' + : 'Mật khẩu không khớp', + style: TextStyle( + fontSize: 14, + color: passwordsMatch.value == true + ? AppColors.success + : AppColors.danger, + ), + ), + ], + ), + ], + + const SizedBox(height: AppSpacing.lg), + + // Security Tips + _buildSecurityTips(), + ], + ), + ), + + const SizedBox(height: AppSpacing.lg), + + // Action Buttons + _buildActionButtons( + context: context, + formKey: formKey, + currentPasswordController: currentPasswordController, + newPasswordController: newPasswordController, + confirmPasswordController: confirmPasswordController, + ), + + const SizedBox(height: AppSpacing.lg), + ], + ), + ), + ), + ); + } + + /// Build password field with show/hide toggle + Widget _buildPasswordField({ + required String label, + required TextEditingController controller, + required ValueNotifier isVisible, + bool required = false, + String? helpText, + 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, + obscureText: !isVisible.value, + 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, + ), + suffixIcon: IconButton( + icon: Icon( + isVisible.value ? Icons.visibility_off : Icons.visibility, + size: 20, + color: AppColors.grey500, + ), + onPressed: () { + isVisible.value = !isVisible.value; + }, + ), + 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), + ), + ), + ), + if (helpText != null) ...[ + const SizedBox(height: 8), + Text( + helpText, + style: const TextStyle(fontSize: 12, color: AppColors.grey500), + ), + ], + ], + ); + } + + /// Build security tips section + Widget _buildSecurityTips() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(AppRadius.card), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Gợi ý bảo mật:', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF212121), + ), + ), + const SizedBox(height: 12), + _buildSecurityTip('Sử dụng ít nhất 8 ký tự'), + _buildSecurityTip('Kết hợp chữ hoa, chữ thường và số'), + _buildSecurityTip('Bao gồm ký tự đặc biệt (!@#\$%^&*)'), + _buildSecurityTip('Không sử dụng thông tin cá nhân'), + _buildSecurityTip('Thường xuyên thay đổi mật khẩu'), + ], + ), + ); + } + + /// Build individual security tip + Widget _buildSecurityTip(String text) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.check_circle_outline, + size: 16, + color: AppColors.success, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF475569), + height: 1.4, + ), + ), + ), + ], + ), + ); + } + + /// Build action buttons + Widget _buildActionButtons({ + required BuildContext context, + required GlobalKey formKey, + required TextEditingController currentPasswordController, + required TextEditingController newPasswordController, + required TextEditingController confirmPasswordController, + }) { + 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), + + // Change Password Button + Expanded( + flex: 2, + child: ElevatedButton.icon( + onPressed: () { + if (formKey.currentState?.validate() ?? false) { + _changePassword( + context, + currentPasswordController.text, + newPasswordController.text, + ); + } + }, + icon: const Icon(Icons.key, size: 20), + label: const Text( + 'Đổi mật khẩu', + 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), + ), + ), + ), + ), + ], + ), + ); + } + + /// Change password + void _changePassword( + BuildContext context, + String currentPassword, + String newPassword, + ) { + // TODO: Implement actual password change with backend + // For now, just show success message + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Mật khẩu đã được thay đổi thành công!'), + backgroundColor: AppColors.success, + duration: Duration(seconds: 2), + ), + ); + + // Navigate back after a short delay + Future.delayed(const Duration(milliseconds: 500), () { + if (context.mounted) { + context.pop(); + } + }); + } +} diff --git a/lib/features/account/presentation/widgets/address_card.dart b/lib/features/account/presentation/widgets/address_card.dart new file mode 100644 index 0000000..940d227 --- /dev/null +++ b/lib/features/account/presentation/widgets/address_card.dart @@ -0,0 +1,194 @@ +/// Address Card Widget +/// +/// Displays a saved address with edit/delete actions. +library; + +import 'package:flutter/material.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; + +/// Address Card Widget +/// +/// Shows address details with name, phone, address text, default badge, +/// and action buttons (edit/delete). +class AddressCard extends StatelessWidget { + final String name; + final String phone; + final String address; + final bool isDefault; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + final VoidCallback? onSetDefault; + + const AddressCard({ + super.key, + required this.name, + required this.phone, + required this.address, + this.isDefault = false, + this.onEdit, + this.onDelete, + this.onSetDefault, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppRadius.card), + border: isDefault + ? Border.all(color: AppColors.primaryBlue, width: 2) + : null, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Address Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header (Name + Badge or Set Default Button) + Row( + children: [ + Flexible( + child: Text( + name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color(0xFF212121), + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + if (isDefault) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.primaryBlue, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'Mặc định', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ) + else if (onSetDefault != null) + TextButton( + onPressed: onSetDefault, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text( + 'Đặt mặc định', + style: TextStyle( + fontSize: 12, + color: AppColors.primaryBlue, + ), + ), + ), + ], + ), + + const SizedBox(height: 4), + + // Phone + Text( + phone, + style: const TextStyle( + fontSize: 14, + color: AppColors.grey500, + ), + ), + + const SizedBox(height: 8), + + // Address Text + Text( + address, + style: const TextStyle( + fontSize: 14, + color: Color(0xFF212121), + height: 1.4, + ), + ), + ], + ), + ), + + const SizedBox(width: 12), + + // Actions + Column( + children: [ + // Edit Button + if (onEdit != null) + InkWell( + onTap: onEdit, + borderRadius: BorderRadius.circular(8), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE2E8F0)), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.edit, + size: 18, + color: AppColors.primaryBlue, + ), + ), + ), + + const SizedBox(height: 8), + + // Delete Button + if (onDelete != null) + InkWell( + onTap: onDelete, + borderRadius: BorderRadius.circular(8), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE2E8F0)), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.delete, + size: 18, + color: AppColors.danger, + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/cart/presentation/pages/cart_page.dart b/lib/features/cart/presentation/pages/cart_page.dart index e28fd58..889f2ad 100644 --- a/lib/features/cart/presentation/pages/cart_page.dart +++ b/lib/features/cart/presentation/pages/cart_page.dart @@ -443,12 +443,7 @@ class _CartPageState extends ConsumerState { child: ElevatedButton( onPressed: cartState.isNotEmpty ? () { - // TODO: Navigate to checkout page when implemented - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Checkout page chưa được triển khai'), - ), - ); + context.push(RouteNames.checkout); } : null, child: const Text( diff --git a/lib/features/cart/presentation/pages/checkout_page.dart b/lib/features/cart/presentation/pages/checkout_page.dart new file mode 100644 index 0000000..d9ffacf --- /dev/null +++ b/lib/features/cart/presentation/pages/checkout_page.dart @@ -0,0 +1,192 @@ +/// Checkout Page +/// +/// Complete checkout flow with delivery info, invoice options, payment methods. +/// Features: +/// - Delivery information form with province/ward dropdowns +/// - Invoice toggle section (checkbox reveals invoice fields) +/// - Payment method selection (bank transfer vs COD) +/// - Order summary with items list +/// - Price negotiation option (hides payment, changes button) +/// - Form validation and submission +library; + +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:worker/core/constants/ui_constants.dart'; +import 'package:worker/features/cart/presentation/widgets/checkout_submit_button.dart'; +import 'package:worker/features/cart/presentation/widgets/delivery_information_section.dart'; +import 'package:worker/features/cart/presentation/widgets/invoice_section.dart'; +import 'package:worker/features/cart/presentation/widgets/order_summary_section.dart'; +import 'package:worker/features/cart/presentation/widgets/payment_method_section.dart'; +import 'package:worker/features/cart/presentation/widgets/price_negotiation_section.dart'; + +/// Checkout Page +/// +/// Full checkout flow for placing orders. +class CheckoutPage extends HookConsumerWidget { + const CheckoutPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Form key for validation + final formKey = useMemoized(() => GlobalKey()); + + // Delivery information controllers + final nameController = useTextEditingController(text: 'Hoàng Minh Hiệp'); + final phoneController = useTextEditingController(text: '0347302911'); + final addressController = useTextEditingController(); + final notesController = useTextEditingController(); + + // Dropdown selections + final selectedProvince = useState('TP.HCM'); + final selectedWard = useState('Quận 1'); + final selectedPickupDate = useState(null); + + // Invoice section + final needsInvoice = useState(false); + final companyNameController = useTextEditingController(); + final taxIdController = useTextEditingController(); + final companyAddressController = useTextEditingController(); + final companyEmailController = useTextEditingController(); + + // Payment method + final paymentMethod = useState('bank_transfer'); + + // Price negotiation + final needsNegotiation = useState(false); + + // Mock cart items + final cartItems = useState>>([ + { + 'id': '1', + 'name': 'Gạch Granite 60x60 Marble White', + 'sku': 'GT-6060-MW', + 'quantity': 20, + 'price': 250000, + 'image': + 'https://images.unsplash.com/photo-1615971677499-5467cbab01c0?w=200', + }, + { + 'id': '2', + 'name': 'Gạch Ceramic 30x60 Wood Effect', + 'sku': 'CR-3060-WE', + 'quantity': 15, + 'price': 180000, + 'image': + 'https://images.unsplash.com/photo-1604709177225-055f99402ea3?w=200', + }, + ]); + + // Calculate totals + final subtotal = cartItems.value.fold( + 0, + (sum, item) => sum + (item['price'] as int) * (item['quantity'] as int), + ); + final discount = subtotal * 0.05; // 5% discount + const shipping = 50000.0; + final total = subtotal - discount + shipping; + + return Scaffold( + backgroundColor: const Color(0xFFF4F6F8), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => context.pop(), + ), + title: const Text( + 'Thanh toán', + style: TextStyle( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: false, + actions: const [SizedBox(width: AppSpacing.sm)], + ), + body: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: AppSpacing.md), + + // Delivery Information Section + DeliveryInformationSection( + nameController: nameController, + phoneController: phoneController, + addressController: addressController, + notesController: notesController, + selectedProvince: selectedProvince, + selectedWard: selectedWard, + selectedPickupDate: selectedPickupDate, + ), + + const SizedBox(height: AppSpacing.md), + + // Invoice Section + InvoiceSection( + needsInvoice: needsInvoice, + companyNameController: companyNameController, + taxIdController: taxIdController, + companyAddressController: companyAddressController, + companyEmailController: companyEmailController, + ), + + const SizedBox(height: AppSpacing.md), + + // Payment Method Section (hidden if negotiation is checked) + if (!needsNegotiation.value) + PaymentMethodSection( + paymentMethod: paymentMethod, + ), + + if (!needsNegotiation.value) const SizedBox(height: AppSpacing.md), + + // Order Summary Section + OrderSummarySection( + cartItems: cartItems.value, + subtotal: subtotal, + discount: discount, + shipping: shipping, + total: total, + ), + + const SizedBox(height: AppSpacing.md), + + // Price Negotiation Section + PriceNegotiationSection( + needsNegotiation: needsNegotiation, + ), + + const SizedBox(height: AppSpacing.md), + + // Place Order Button + CheckoutSubmitButton( + formKey: formKey, + needsNegotiation: needsNegotiation.value, + needsInvoice: needsInvoice.value, + name: nameController.text, + phone: phoneController.text, + address: addressController.text, + province: selectedProvince.value, + ward: selectedWard.value, + paymentMethod: paymentMethod.value, + companyName: companyNameController.text, + taxId: taxIdController.text, + total: total, + ), + + const SizedBox(height: AppSpacing.lg), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/cart/presentation/widgets/checkout_date_picker_field.dart b/lib/features/cart/presentation/widgets/checkout_date_picker_field.dart new file mode 100644 index 0000000..820c354 --- /dev/null +++ b/lib/features/cart/presentation/widgets/checkout_date_picker_field.dart @@ -0,0 +1,85 @@ +/// Checkout Date Picker Field Widget +/// +/// Reusable date picker field for checkout forms. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; + +/// Checkout Date Picker Field +/// +/// Date picker field with calendar dialog. +class CheckoutDatePickerField extends HookWidget { + final String label; + final ValueNotifier selectedDate; + + const CheckoutDatePickerField({ + super.key, + required this.label, + required this.selectedDate, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Ngày nhận hàng mong muốn', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 8), + InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now().add(const Duration(days: 1)), + firstDate: DateTime.now().add(const Duration(days: 1)), + lastDate: DateTime.now().add(const Duration(days: 30)), + ); + if (picked != null) { + selectedDate.value = picked; + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(AppRadius.input), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selectedDate.value != null + ? _formatDate(selectedDate.value!) + : 'Chọn ngày', + style: TextStyle( + fontSize: 14, + color: selectedDate.value != null + ? const Color(0xFF212121) + : AppColors.grey500.withValues(alpha: 0.6), + ), + ), + const Icon(Icons.calendar_today, + size: 20, color: AppColors.grey500), + ], + ), + ), + ), + ], + ); + } + + /// Format date + String _formatDate(DateTime date) { + return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; + } +} diff --git a/lib/features/cart/presentation/widgets/checkout_dropdown_field.dart b/lib/features/cart/presentation/widgets/checkout_dropdown_field.dart new file mode 100644 index 0000000..3d06dc8 --- /dev/null +++ b/lib/features/cart/presentation/widgets/checkout_dropdown_field.dart @@ -0,0 +1,94 @@ +/// Checkout Dropdown Field Widget +/// +/// Reusable dropdown field for checkout forms. +library; + +import 'package:flutter/material.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; + +/// Checkout Dropdown Field +/// +/// Styled dropdown field with label and validation. +class CheckoutDropdownField extends StatelessWidget { + final String label; + final String? value; + final bool required; + final List items; + final void Function(String?) onChanged; + + const CheckoutDropdownField({ + super.key, + required this.label, + required this.value, + required this.required, + required this.items, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + 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), + DropdownButtonFormField( + initialValue: value, + 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( + value: item, + child: Text(item), + ); + }).toList(), + onChanged: onChanged, + validator: (value) { + if (required && (value == null || value.isEmpty)) { + return 'Vui lòng chọn $label'; + } + return null; + }, + ), + ], + ); + } +} diff --git a/lib/features/cart/presentation/widgets/checkout_submit_button.dart b/lib/features/cart/presentation/widgets/checkout_submit_button.dart new file mode 100644 index 0000000..dac9fac --- /dev/null +++ b/lib/features/cart/presentation/widgets/checkout_submit_button.dart @@ -0,0 +1,124 @@ +/// Checkout Submit Button Widget +/// +/// Place order or send negotiation request button. +library; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; + +/// Checkout Submit Button +/// +/// Button that changes based on negotiation checkbox state. +class CheckoutSubmitButton extends StatelessWidget { + final GlobalKey formKey; + final bool needsNegotiation; + final bool needsInvoice; + final String name; + final String phone; + final String address; + final String? province; + final String? ward; + final String paymentMethod; + final String companyName; + final String taxId; + final double total; + + const CheckoutSubmitButton({ + super.key, + required this.formKey, + required this.needsNegotiation, + required this.needsInvoice, + required this.name, + required this.phone, + required this.address, + required this.province, + required this.ward, + required this.paymentMethod, + required this.companyName, + required this.taxId, + required this.total, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Column( + children: [ + // Terms Agreement Text + const Text( + 'Bằng việc đặt hàng, bạn đồng ý với các điều khoản và điều kiện của chúng tôi', + style: TextStyle(fontSize: 12, color: AppColors.grey500), + textAlign: TextAlign.center, + ), + + const SizedBox(height: AppSpacing.md), + + // Place Order / Send Negotiation Button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (formKey.currentState?.validate() ?? false) { + _handlePlaceOrder(context); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: needsNegotiation + ? AppColors.warning + : AppColors.primaryBlue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + child: Text( + needsNegotiation ? 'Gửi yêu cầu đàm phán' : 'Đặt hàng', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ); + } + + /// Handle place order + void _handlePlaceOrder(BuildContext context) { + // TODO: Implement actual order placement with backend + + if (needsNegotiation) { + // Show negotiation request sent message + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Yêu cầu đàm phán giá đã được gửi!'), + backgroundColor: AppColors.success, + duration: Duration(seconds: 2), + ), + ); + } else { + // Show order success message + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Đặt hàng thành công!'), + backgroundColor: AppColors.success, + duration: Duration(seconds: 2), + ), + ); + } + + // Navigate back after a short delay + Future.delayed(const Duration(milliseconds: 500), () { + if (context.mounted) { + context.pop(); + } + }); + } +} diff --git a/lib/features/cart/presentation/widgets/checkout_text_field.dart b/lib/features/cart/presentation/widgets/checkout_text_field.dart new file mode 100644 index 0000000..3f23302 --- /dev/null +++ b/lib/features/cart/presentation/widgets/checkout_text_field.dart @@ -0,0 +1,101 @@ +/// Checkout Text Field Widget +/// +/// Reusable text field for checkout forms. +library; + +import 'package:flutter/material.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; + +/// Checkout Text Field +/// +/// Styled text field with label, validation, and optional features. +class CheckoutTextField extends StatelessWidget { + final String label; + final TextEditingController controller; + final bool required; + final String? hintText; + final int maxLines; + final TextInputType? keyboardType; + final String? Function(String?)? validator; + + const CheckoutTextField({ + super.key, + required this.label, + required this.controller, + required this.required, + this.hintText, + this.maxLines = 1, + this.keyboardType, + this.validator, + }); + + @override + Widget build(BuildContext context) { + 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, + maxLines: maxLines, + keyboardType: keyboardType, + validator: validator, + decoration: InputDecoration( + hintText: 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), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/cart/presentation/widgets/delivery_information_section.dart b/lib/features/cart/presentation/widgets/delivery_information_section.dart new file mode 100644 index 0000000..62a15b9 --- /dev/null +++ b/lib/features/cart/presentation/widgets/delivery_information_section.dart @@ -0,0 +1,177 @@ +/// Delivery Information Section Widget +/// +/// Form section for delivery details including name, phone, address, pickup date. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/features/cart/presentation/widgets/checkout_date_picker_field.dart'; +import 'package:worker/features/cart/presentation/widgets/checkout_dropdown_field.dart'; +import 'package:worker/features/cart/presentation/widgets/checkout_text_field.dart'; + +/// Delivery Information Section +/// +/// Collects delivery details from the user with validation. +class DeliveryInformationSection extends HookWidget { + final TextEditingController nameController; + final TextEditingController phoneController; + final TextEditingController addressController; + final TextEditingController notesController; + final ValueNotifier selectedProvince; + final ValueNotifier selectedWard; + final ValueNotifier selectedPickupDate; + + const DeliveryInformationSection({ + super.key, + required this.nameController, + required this.phoneController, + required this.addressController, + required this.notesController, + required this.selectedProvince, + required this.selectedWard, + required this.selectedPickupDate, + }); + + @override + Widget build(BuildContext context) { + return 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: [ + // Section Title + const Text( + 'Thông tin giao hàng', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF212121), + ), + ), + + const SizedBox(height: AppSpacing.lg), + + // Name Field + CheckoutTextField( + label: 'Họ và tên người nhận', + controller: nameController, + required: true, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Vui lòng nhập họ tên'; + } + return null; + }, + ), + + const SizedBox(height: AppSpacing.md), + + // Phone Field + CheckoutTextField( + 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'; + } + if (!RegExp(r'^0\d{9}$').hasMatch(value)) { + return 'Số điện thoại không hợp lệ'; + } + return null; + }, + ), + + const SizedBox(height: AppSpacing.md), + + // Province Dropdown + CheckoutDropdownField( + label: 'Tỉnh/Thành phố', + value: selectedProvince.value, + required: true, + items: const [ + 'TP.HCM', + 'Hà Nội', + 'Đà Nẵng', + 'Cần Thơ', + 'Biên Hòa', + ], + onChanged: (value) { + selectedProvince.value = value; + }, + ), + + const SizedBox(height: AppSpacing.md), + + // Ward Dropdown + CheckoutDropdownField( + label: 'Quận/Huyện', + value: selectedWard.value, + required: true, + items: const [ + 'Quận 1', + 'Quận 2', + 'Quận 3', + 'Quận 4', + 'Quận 5', + 'Thủ Đức', + ], + onChanged: (value) { + selectedWard.value = value; + }, + ), + + const SizedBox(height: AppSpacing.md), + + // Specific Address + CheckoutTextField( + label: 'Địa chỉ cụ thể', + controller: addressController, + required: true, + maxLines: 2, + hintText: 'Số nhà, tên đường, phường/xã', + validator: (value) { + if (value == null || value.isEmpty) { + return 'Vui lòng nhập địa chỉ cụ thể'; + } + return null; + }, + ), + + const SizedBox(height: AppSpacing.md), + + // Pickup Date + CheckoutDatePickerField( + label: 'Ngày nhận hàng mong muốn', + selectedDate: selectedPickupDate, + ), + + const SizedBox(height: AppSpacing.md), + + // Notes + CheckoutTextField( + label: 'Ghi chú', + controller: notesController, + required: false, + maxLines: 3, + hintText: 'Ghi chú thêm cho đơn hàng (không bắt buộc)', + ), + ], + ), + ); + } +} diff --git a/lib/features/cart/presentation/widgets/invoice_section.dart b/lib/features/cart/presentation/widgets/invoice_section.dart new file mode 100644 index 0000000..ea6a4f2 --- /dev/null +++ b/lib/features/cart/presentation/widgets/invoice_section.dart @@ -0,0 +1,147 @@ +/// Invoice Section Widget +/// +/// Optional invoice information form section. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/cart/presentation/widgets/checkout_text_field.dart'; + +/// Invoice Section +/// +/// Collects invoice/VAT information when checkbox is enabled. +class InvoiceSection extends HookWidget { + final ValueNotifier needsInvoice; + final TextEditingController companyNameController; + final TextEditingController taxIdController; + final TextEditingController companyAddressController; + final TextEditingController companyEmailController; + + const InvoiceSection({ + super.key, + required this.needsInvoice, + required this.companyNameController, + required this.taxIdController, + required this.companyAddressController, + required this.companyEmailController, + }); + + @override + Widget build(BuildContext context) { + return 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: [ + // Invoice Checkbox + Row( + children: [ + Checkbox( + value: needsInvoice.value, + onChanged: (value) { + needsInvoice.value = value ?? false; + }, + activeColor: AppColors.primaryBlue, + ), + const Expanded( + child: Text( + 'Xuất hóa đơn VAT', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF212121), + ), + ), + ), + ], + ), + + // Invoice Fields (visible when checkbox is checked) + if (needsInvoice.value) ...[ + const SizedBox(height: AppSpacing.md), + + // Company Name + CheckoutTextField( + label: 'Tên công ty', + controller: companyNameController, + required: true, + validator: (value) { + if (needsInvoice.value && (value == null || value.isEmpty)) { + return 'Vui lòng nhập tên công ty'; + } + return null; + }, + ), + + const SizedBox(height: AppSpacing.md), + + // Tax ID + CheckoutTextField( + label: 'Mã số thuế', + controller: taxIdController, + required: true, + keyboardType: TextInputType.number, + validator: (value) { + if (needsInvoice.value && (value == null || value.isEmpty)) { + return 'Vui lòng nhập mã số thuế'; + } + return null; + }, + ), + + const SizedBox(height: AppSpacing.md), + + // Company Address + CheckoutTextField( + label: 'Địa chỉ công ty', + controller: companyAddressController, + required: true, + maxLines: 2, + validator: (value) { + if (needsInvoice.value && (value == null || value.isEmpty)) { + return 'Vui lòng nhập địa chỉ công ty'; + } + return null; + }, + ), + + const SizedBox(height: AppSpacing.md), + + // Company Email + CheckoutTextField( + label: 'Email nhận hóa đơn', + controller: companyEmailController, + required: true, + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (needsInvoice.value && (value == null || value.isEmpty)) { + return 'Vui lòng nhập email'; + } + if (needsInvoice.value && + !RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$') + .hasMatch(value!)) { + return 'Email không hợp lệ'; + } + return null; + }, + ), + ], + ], + ), + ); + } +} diff --git a/lib/features/cart/presentation/widgets/order_summary_section.dart b/lib/features/cart/presentation/widgets/order_summary_section.dart new file mode 100644 index 0000000..8449427 --- /dev/null +++ b/lib/features/cart/presentation/widgets/order_summary_section.dart @@ -0,0 +1,215 @@ +/// Order Summary Section Widget +/// +/// Displays cart items and price breakdown. +library; + +import 'package:flutter/material.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; + +/// Order Summary Section +/// +/// Shows order items, subtotal, discount, shipping, and total. +class OrderSummarySection extends StatelessWidget { + final List> cartItems; + final double subtotal; + final double discount; + final double shipping; + final double total; + + const OrderSummarySection({ + super.key, + required this.cartItems, + required this.subtotal, + required this.discount, + required this.shipping, + required this.total, + }); + + @override + Widget build(BuildContext context) { + return 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: [ + // Section Title + const Text( + 'Tóm tắt đơn hàng', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF212121), + ), + ), + + const SizedBox(height: AppSpacing.md), + + // Cart Items + ...cartItems.map((item) => _buildCartItem(item)), + + const Divider(height: 32), + + // Subtotal + _buildSummaryRow('Tạm tính', subtotal), + const SizedBox(height: 8), + + // Discount + _buildSummaryRow('Giảm giá (5%)', -discount, isDiscount: true), + const SizedBox(height: 8), + + // Shipping + _buildSummaryRow('Phí vận chuyển', shipping), + + const Divider(height: 24), + + // Total + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Tổng cộng', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF212121), + ), + ), + Text( + _formatCurrency(total), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.primaryBlue, + ), + ), + ], + ), + ], + ), + ); + } + + /// Build cart item row + Widget _buildCartItem(Map item) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + // Product Image + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: AppColors.grey100, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + item['image'] as String, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Icon(Icons.image, color: AppColors.grey500); + }, + ), + ), + ), + + const SizedBox(width: 12), + + // Product Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item['name'] as String, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF212121), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'Mã: ${item['sku']}', + style: + const TextStyle(fontSize: 12, color: AppColors.grey500), + ), + ], + ), + ), + + const SizedBox(width: 12), + + // Quantity and Price + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'SL: ${item['quantity']}', + style: const TextStyle(fontSize: 13, color: AppColors.grey500), + ), + const SizedBox(height: 4), + Text( + _formatCurrency( + ((item['price'] as int) * (item['quantity'] as int)) + .toDouble()), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.primaryBlue, + ), + ), + ], + ), + ], + ), + ); + } + + /// Build summary row + Widget _buildSummaryRow(String label, double amount, + {bool isDiscount = false}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle(fontSize: 14, color: AppColors.grey500), + ), + Text( + _formatCurrency(amount), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: isDiscount ? AppColors.danger : const Color(0xFF212121), + ), + ), + ], + ); + } + + /// Format currency + String _formatCurrency(double amount) { + return '${amount.toStringAsFixed(0).replaceAllMapped( + RegExp(r'(\d)(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]}.', + )}₫'; + } +} diff --git a/lib/features/cart/presentation/widgets/payment_method_section.dart b/lib/features/cart/presentation/widgets/payment_method_section.dart new file mode 100644 index 0000000..cfaa012 --- /dev/null +++ b/lib/features/cart/presentation/widgets/payment_method_section.dart @@ -0,0 +1,134 @@ +/// Payment Method Section Widget +/// +/// Payment method selection (Bank Transfer or COD). +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; + +/// Payment Method Section +/// +/// Allows user to select payment method between bank transfer and COD. +class PaymentMethodSection extends HookWidget { + final ValueNotifier paymentMethod; + + const PaymentMethodSection({ + super.key, + required this.paymentMethod, + }); + + @override + Widget build(BuildContext context) { + return 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: [ + // Section Title + const Text( + 'Phương thức thanh toán', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF212121), + ), + ), + + const SizedBox(height: AppSpacing.md), + + // Bank Transfer Option + InkWell( + onTap: () => paymentMethod.value = 'bank_transfer', + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Radio( + value: 'bank_transfer', + groupValue: paymentMethod.value, + onChanged: (value) { + paymentMethod.value = value!; + }, + activeColor: AppColors.primaryBlue, + ), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Chuyển khoản ngân hàng', + style: TextStyle( + fontSize: 15, fontWeight: FontWeight.w500), + ), + SizedBox(height: 4), + Text( + 'Thanh toán qua chuyển khoản', + style: + TextStyle(fontSize: 13, color: AppColors.grey500), + ), + ], + ), + ), + ], + ), + ), + ), + + const Divider(height: 1), + + // COD Option + InkWell( + onTap: () => paymentMethod.value = 'cod', + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Radio( + value: 'cod', + groupValue: paymentMethod.value, + onChanged: (value) { + paymentMethod.value = value!; + }, + activeColor: AppColors.primaryBlue, + ), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Thanh toán khi nhận hàng (COD)', + style: TextStyle( + fontSize: 15, fontWeight: FontWeight.w500), + ), + SizedBox(height: 4), + Text( + 'Thanh toán bằng tiền mặt khi nhận hàng', + style: + TextStyle(fontSize: 13, color: AppColors.grey500), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/cart/presentation/widgets/price_negotiation_section.dart b/lib/features/cart/presentation/widgets/price_negotiation_section.dart new file mode 100644 index 0000000..6895644 --- /dev/null +++ b/lib/features/cart/presentation/widgets/price_negotiation_section.dart @@ -0,0 +1,65 @@ +/// Price Negotiation Section Widget +/// +/// Optional price negotiation checkbox. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; + +/// Price Negotiation Section +/// +/// Allows user to request price negotiation instead of direct order. +class PriceNegotiationSection extends HookWidget { + final ValueNotifier needsNegotiation; + + const PriceNegotiationSection({ + super.key, + required this.needsNegotiation, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: const Color(0xFFFFF8E1), + borderRadius: BorderRadius.circular(AppRadius.card), + border: Border.all(color: const Color(0xFFFFD54F)), + ), + child: Row( + children: [ + Checkbox( + value: needsNegotiation.value, + onChanged: (value) { + needsNegotiation.value = value ?? false; + }, + activeColor: AppColors.warning, + ), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Đàm phán giá', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF212121), + ), + ), + SizedBox(height: 4), + Text( + 'Gửi yêu cầu đàm phán giá cho đơn hàng này', + style: TextStyle(fontSize: 13, color: AppColors.grey500), + ), + ], + ), + ), + ], + ), + ); + } +}