This commit is contained in:
Phuoc Nguyen
2025-11-18 17:59:27 +07:00
parent 0dda402246
commit fc4711a18e
8 changed files with 568 additions and 285 deletions

View File

@@ -62,13 +62,18 @@ final routerProvider = Provider<GoRouter>((ref) {
final isLoggedIn = authState.value != null; final isLoggedIn = authState.value != null;
final isOnSplashPage = state.matchedLocation == RouteNames.splash; final isOnSplashPage = state.matchedLocation == RouteNames.splash;
final isOnLoginPage = state.matchedLocation == RouteNames.login; final isOnLoginPage = state.matchedLocation == RouteNames.login;
final isOnForgotPasswordPage = state.matchedLocation == RouteNames.forgotPassword; final isOnForgotPasswordPage =
state.matchedLocation == RouteNames.forgotPassword;
final isOnRegisterPage = state.matchedLocation == RouteNames.register; final isOnRegisterPage = state.matchedLocation == RouteNames.register;
final isOnBusinessUnitPage = final isOnBusinessUnitPage =
state.matchedLocation == RouteNames.businessUnitSelection; state.matchedLocation == RouteNames.businessUnitSelection;
final isOnOtpPage = state.matchedLocation == RouteNames.otpVerification; final isOnOtpPage = state.matchedLocation == RouteNames.otpVerification;
final isOnAuthPage = final isOnAuthPage =
isOnLoginPage || isOnForgotPasswordPage || isOnRegisterPage || isOnBusinessUnitPage || isOnOtpPage; isOnLoginPage ||
isOnForgotPasswordPage ||
isOnRegisterPage ||
isOnBusinessUnitPage ||
isOnOtpPage;
// While loading auth state, show splash screen // While loading auth state, show splash screen
if (isLoading) { if (isLoading) {
@@ -367,8 +372,13 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute( GoRoute(
path: RouteNames.addresses, path: RouteNames.addresses,
name: RouteNames.addresses, name: RouteNames.addresses,
pageBuilder: (context, state) => pageBuilder: (context, state) {
MaterialPage(key: state.pageKey, child: const AddressesPage()), final extra = state.extra as Map<String, dynamic>?;
return MaterialPage(
key: state.pageKey,
child: AddressesPage(extra: extra),
);
},
), ),
// Address Form Route (Create/Edit) // Address Form Route (Create/Edit)

View File

@@ -10,6 +10,7 @@
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -23,11 +24,21 @@ import 'package:worker/features/account/presentation/widgets/address_card.dart';
/// Addresses Page /// Addresses Page
/// ///
/// Page for managing saved delivery addresses. /// Page for managing saved delivery addresses.
class AddressesPage extends ConsumerWidget { /// Supports selection mode for returning selected address.
const AddressesPage({super.key}); class AddressesPage extends HookConsumerWidget {
const AddressesPage({super.key, this.extra});
final Map<String, dynamic>? extra;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// Check if in selection mode
final selectMode = extra?['selectMode'] == true;
final currentAddress = extra?['currentAddress'] as Address?;
// Selected address state (for selection mode)
final selectedAddress = useState<Address?>(currentAddress);
// Watch addresses from API // Watch addresses from API
final addressesAsync = ref.watch(addressesProvider); final addressesAsync = ref.watch(addressesProvider);
@@ -37,18 +48,26 @@ class AddressesPage extends ConsumerWidget {
backgroundColor: AppColors.white, backgroundColor: AppColors.white,
elevation: AppBarSpecs.elevation, elevation: AppBarSpecs.elevation,
leading: IconButton( leading: IconButton(
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20), icon: const FaIcon(
FontAwesomeIcons.arrowLeft,
color: Colors.black,
size: 20,
),
onPressed: () => context.pop(), onPressed: () => context.pop(),
), ),
title: const Text( title: Text(
'Địa chỉ của bạn', selectMode ? 'Chọn địa chỉ' : 'Địa chỉ của bạn',
style: TextStyle(color: Colors.black), style: const TextStyle(color: Colors.black),
), ),
foregroundColor: AppColors.grey900, foregroundColor: AppColors.grey900,
centerTitle: false, centerTitle: false,
actions: [ actions: [
IconButton( IconButton(
icon: const FaIcon(FontAwesomeIcons.circleInfo, color: Colors.black, size: 20), icon: const FaIcon(
FontAwesomeIcons.circleInfo,
color: Colors.black,
size: 20,
),
onPressed: () { onPressed: () {
_showInfoDialog(context); _showInfoDialog(context);
}, },
@@ -74,6 +93,37 @@ class AddressesPage extends ConsumerWidget {
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final address = addresses[index]; final address = addresses[index];
final isSelected =
selectedAddress.value?.name == address.name;
// In selection mode, show radio button
if (selectMode) {
return AddressCard(
name: address.addressTitle,
phone: address.phone,
address: address.fullAddress,
isDefault: address.isDefault,
showRadio: true,
isSelected: isSelected,
onRadioTap: () {
selectedAddress.value = address;
},
// Keep edit/delete actions in selection mode
onEdit: () {
context.push(
RouteNames.addressForm,
extra: address,
);
},
onDelete: () {
_showDeleteConfirmation(context, ref, address);
},
onSetDefault:
null, // Hide set default in selection mode
);
}
// Normal mode - show all actions
return AddressCard( return AddressCard(
name: address.addressTitle, name: address.addressTitle,
phone: address.phone, phone: address.phone,
@@ -97,10 +147,81 @@ class AddressesPage extends ConsumerWidget {
), ),
), ),
// Add New Address Button // Bottom Buttons
Padding( Padding(
padding: const EdgeInsets.all(AppSpacing.md), padding: const EdgeInsets.all(AppSpacing.md),
child: SizedBox( child: selectMode
? Row(
children: [
// Add New Address Button (Selection Mode)
Expanded(
child: OutlinedButton.icon(
onPressed: () {
context.push(RouteNames.addressForm);
},
icon: const FaIcon(FontAwesomeIcons.plus, size: 16),
label: const Text(
'Thêm mới',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryBlue,
side: const BorderSide(
color: AppColors.primaryBlue,
width: 1.5,
),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppRadius.button,
),
),
),
),
),
const SizedBox(width: AppSpacing.md),
// Select Address Button
Expanded(
flex: 2,
child: ElevatedButton.icon(
onPressed: selectedAddress.value == null
? null
: () {
// Return selected address without setting as default
context.pop(selectedAddress.value);
},
icon: const FaIcon(
FontAwesomeIcons.check,
size: 16,
),
label: const Text(
'Chọn địa chỉ này',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppRadius.button,
),
),
),
),
),
],
)
: SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () { onPressed: () {
@@ -109,7 +230,10 @@ class AddressesPage extends ConsumerWidget {
icon: const FaIcon(FontAwesomeIcons.plus, size: 18), icon: const FaIcon(FontAwesomeIcons.plus, size: 18),
label: const Text( label: const Text(
'Thêm địa chỉ mới', 'Thêm địa chỉ mới',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue, backgroundColor: AppColors.primaryBlue,
@@ -117,7 +241,9 @@ class AddressesPage extends ConsumerWidget {
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button), borderRadius: BorderRadius.circular(
AppRadius.button,
),
), ),
), ),
), ),
@@ -166,7 +292,6 @@ class AddressesPage extends ConsumerWidget {
), ),
), ),
), ),
); );
} }
@@ -224,7 +349,11 @@ class AddressesPage extends ConsumerWidget {
} }
/// Set address as default /// Set address as default
void _setDefaultAddress(BuildContext context, WidgetRef ref, Address address) { void _setDefaultAddress(
BuildContext context,
WidgetRef ref,
Address address,
) {
ref.read(addressesProvider.notifier).setDefaultAddress(address.name); ref.read(addressesProvider.notifier).setDefaultAddress(address.name);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -12,6 +12,7 @@ import 'package:worker/core/theme/colors.dart';
/// ///
/// Shows address details with name, phone, address text, default badge, /// Shows address details with name, phone, address text, default badge,
/// and action buttons (edit/delete). /// and action buttons (edit/delete).
/// Supports selection mode with radio button.
class AddressCard extends StatelessWidget { class AddressCard extends StatelessWidget {
final String name; final String name;
final String phone; final String phone;
@@ -20,6 +21,9 @@ class AddressCard extends StatelessWidget {
final VoidCallback? onEdit; final VoidCallback? onEdit;
final VoidCallback? onDelete; final VoidCallback? onDelete;
final VoidCallback? onSetDefault; final VoidCallback? onSetDefault;
final bool showRadio;
final bool isSelected;
final VoidCallback? onRadioTap;
const AddressCard({ const AddressCard({
super.key, super.key,
@@ -30,6 +34,9 @@ class AddressCard extends StatelessWidget {
this.onEdit, this.onEdit,
this.onDelete, this.onDelete,
this.onSetDefault, this.onSetDefault,
this.showRadio = false,
this.isSelected = false,
this.onRadioTap,
}); });
@override @override
@@ -61,6 +68,21 @@ class AddressCard extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Radio Button (Selection Mode)
if (showRadio)
GestureDetector(
onTap: onRadioTap,
child: Padding(
padding: const EdgeInsets.only(right: 12, top: 2),
child: Radio<bool>(
value: true,
groupValue: isSelected,
onChanged: (_) => onRadioTap?.call(),
activeColor: AppColors.primaryBlue,
),
),
),
// Address Content // Address Content
Expanded( Expanded(
child: Column( child: Column(
@@ -111,7 +133,9 @@ class AddressCard extends StatelessWidget {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(
color: AppColors.primaryBlue.withValues(alpha: 0.3), color: AppColors.primaryBlue.withValues(
alpha: 0.3,
),
), ),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),

View File

@@ -17,6 +17,7 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/features/account/domain/entities/address.dart';
import 'package:worker/features/cart/presentation/widgets/checkout_submit_button.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/delivery_information_section.dart';
import 'package:worker/features/cart/presentation/widgets/invoice_section.dart'; import 'package:worker/features/cart/presentation/widgets/invoice_section.dart';
@@ -35,23 +36,13 @@ class CheckoutPage extends HookConsumerWidget {
// Form key for validation // Form key for validation
final formKey = useMemoized(() => GlobalKey<FormState>()); final formKey = useMemoized(() => GlobalKey<FormState>());
// Delivery information controllers // Delivery information
final nameController = useTextEditingController(text: 'Hoàng Minh Hiệp');
final phoneController = useTextEditingController(text: '0347302911');
final addressController = useTextEditingController();
final notesController = useTextEditingController(); final notesController = useTextEditingController();
// Dropdown selections
final selectedProvince = useState<String?>('TP.HCM');
final selectedWard = useState<String?>('Quận 1');
final selectedPickupDate = useState<DateTime?>(null); final selectedPickupDate = useState<DateTime?>(null);
final selectedAddress = useState<Address?>(null);
// Invoice section // Invoice section
final needsInvoice = useState<bool>(false); final needsInvoice = useState<bool>(false);
final companyNameController = useTextEditingController();
final taxIdController = useTextEditingController();
final companyAddressController = useTextEditingController();
final companyEmailController = useTextEditingController();
// Payment method // Payment method
final paymentMethod = useState<String>('full_payment'); final paymentMethod = useState<String>('full_payment');
@@ -96,7 +87,11 @@ class CheckoutPage extends HookConsumerWidget {
backgroundColor: Colors.white, backgroundColor: Colors.white,
elevation: 0, elevation: 0,
leading: IconButton( leading: IconButton(
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20), icon: const FaIcon(
FontAwesomeIcons.arrowLeft,
color: Colors.black,
size: 20,
),
onPressed: () => context.pop(), onPressed: () => context.pop(),
), ),
title: const Text( title: const Text(
@@ -119,25 +114,15 @@ class CheckoutPage extends HookConsumerWidget {
// Delivery Information Section // Delivery Information Section
DeliveryInformationSection( DeliveryInformationSection(
nameController: nameController,
phoneController: phoneController,
addressController: addressController,
notesController: notesController, notesController: notesController,
selectedProvince: selectedProvince,
selectedWard: selectedWard,
selectedPickupDate: selectedPickupDate, selectedPickupDate: selectedPickupDate,
selectedAddress: selectedAddress,
), ),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
// Invoice Section // Invoice Section
InvoiceSection( InvoiceSection(needsInvoice: needsInvoice),
needsInvoice: needsInvoice,
companyNameController: companyNameController,
taxIdController: taxIdController,
companyAddressController: companyAddressController,
companyEmailController: companyEmailController,
),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
@@ -169,14 +154,8 @@ class CheckoutPage extends HookConsumerWidget {
formKey: formKey, formKey: formKey,
needsNegotiation: needsNegotiation.value, needsNegotiation: needsNegotiation.value,
needsInvoice: needsInvoice.value, needsInvoice: needsInvoice.value,
name: nameController.text, selectedAddress: selectedAddress.value,
phone: phoneController.text,
address: addressController.text,
province: selectedProvince.value,
ward: selectedWard.value,
paymentMethod: paymentMethod.value, paymentMethod: paymentMethod.value,
companyName: companyNameController.text,
taxId: taxIdController.text,
total: total, total: total,
), ),

View File

@@ -9,40 +9,29 @@ import 'package:go_router/go_router.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart'; import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/account/domain/entities/address.dart';
/// Checkout Submit Button /// Checkout Submit Button
/// ///
/// Button that changes based on negotiation checkbox state. /// Button that changes based on negotiation checkbox state.
class CheckoutSubmitButton extends StatelessWidget { class CheckoutSubmitButton extends StatelessWidget {
final GlobalKey<FormState> 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({ const CheckoutSubmitButton({
super.key, super.key,
required this.formKey, required this.formKey,
required this.needsNegotiation, required this.needsNegotiation,
required this.needsInvoice, required this.needsInvoice,
required this.name, required this.selectedAddress,
required this.phone,
required this.address,
required this.province,
required this.ward,
required this.paymentMethod, required this.paymentMethod,
required this.companyName,
required this.taxId,
required this.total, required this.total,
}); });
final GlobalKey<FormState> formKey;
final bool needsNegotiation;
final bool needsInvoice;
final Address? selectedAddress;
final String paymentMethod;
final double total;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
@@ -63,6 +52,18 @@ class CheckoutSubmitButton extends StatelessWidget {
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
// Validate address is selected
if (selectedAddress == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vui lòng chọn địa chỉ giao hàng'),
backgroundColor: AppColors.danger,
duration: Duration(seconds: 2),
),
);
return;
}
if (formKey.currentState?.validate() ?? false) { if (formKey.currentState?.validate() ?? false) {
_handlePlaceOrder(context); _handlePlaceOrder(context);
} }

View File

@@ -1,40 +1,55 @@
/// Delivery Information Section Widget /// Delivery Information Section Widget
/// ///
/// Form section for delivery details including name, phone, address, pickup date. /// Shows delivery address selection and pickup date/notes.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/account/domain/entities/address.dart';
import 'package:worker/features/account/presentation/providers/address_provider.dart';
import 'package:worker/features/cart/presentation/widgets/checkout_date_picker_field.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'; import 'package:worker/features/cart/presentation/widgets/checkout_text_field.dart';
/// Delivery Information Section /// Delivery Information Section
/// ///
/// Collects delivery details from the user with validation. /// Shows default address selection and delivery details.
class DeliveryInformationSection extends HookWidget { /// Matches HTML design from checkout.html.
final TextEditingController nameController; class DeliveryInformationSection extends HookConsumerWidget {
final TextEditingController phoneController;
final TextEditingController addressController;
final TextEditingController notesController;
final ValueNotifier<String?> selectedProvince;
final ValueNotifier<String?> selectedWard;
final ValueNotifier<DateTime?> selectedPickupDate;
const DeliveryInformationSection({ const DeliveryInformationSection({
super.key, super.key,
required this.nameController,
required this.phoneController,
required this.addressController,
required this.notesController, required this.notesController,
required this.selectedProvince,
required this.selectedWard,
required this.selectedPickupDate, required this.selectedPickupDate,
required this.selectedAddress,
}); });
final TextEditingController notesController;
final ValueNotifier<DateTime?> selectedPickupDate;
final ValueNotifier<Address?> selectedAddress;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
// Watch the default address
final defaultAddr = ref.watch(defaultAddressProvider);
// Initialize selected address with default address when it loads
// Only update if user hasn't manually selected a different address
useEffect(() {
if (defaultAddr != null && selectedAddress.value == null) {
// Use Future.microtask to avoid setState during build
Future.microtask(() {
if (selectedAddress.value == null) {
selectedAddress.value = defaultAddr;
}
});
}
return null;
}, [defaultAddr]); // Watch defaultAddr changes
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md), margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md), padding: const EdgeInsets.all(AppSpacing.md),
@@ -53,104 +68,166 @@ class DeliveryInformationSection extends HookWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Section Title // Section Title
Row(
children: [
const FaIcon(
FontAwesomeIcons.truck,
color: AppColors.primaryBlue,
size: 16,
),
const SizedBox(width: AppSpacing.sm),
const Text( const Text(
'Thông tin giao hàng', 'Thông tin giao hàng',
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w600,
color: Color(0xFF212121), 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), const SizedBox(height: AppSpacing.md),
// Specific Address // Address Selection
CheckoutTextField( Column(
label: 'Địa chỉ cụ thể', crossAxisAlignment: CrossAxisAlignment.start,
controller: addressController, children: [
required: true, const Text(
maxLines: 2, 'Địa chỉ nhận hàng',
hintText: 'Số nhà, tên đường, phường/xã', style: TextStyle(
validator: (value) { fontSize: 13,
if (value == null || value.isEmpty) { fontWeight: FontWeight.w500,
return 'Vui lòng nhập địa chỉ cụ thể'; color: Color(0xFF424242),
} ),
return null; ),
const SizedBox(height: AppSpacing.sm),
// Address Card
if (selectedAddress.value != null)
InkWell(
onTap: () async {
// Navigate to address selection and wait for result
final result = await context.push<Address>(
'/account/addresses',
extra: {
'selectMode': true,
'currentAddress': selectedAddress.value,
}, },
);
// Update selected address if user picked one
if (result != null) {
selectedAddress.value = result;
}
},
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
padding: const EdgeInsets.all(AppSpacing.sm),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE0E0E0)),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name
Text(
selectedAddress.value!.addressTitle,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF212121),
),
),
const SizedBox(height: 4),
// Phone
Text(
selectedAddress.value!.phone,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF757575),
),
),
const SizedBox(height: 2),
// Address
Text(
selectedAddress.value!.fullAddress,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF757575),
),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
const FaIcon(
FontAwesomeIcons.chevronRight,
size: 14,
color: Color(0xFF9E9E9E),
),
],
),
),
)
else
// No address selected - show add button
InkWell(
onTap: () async {
final result = await context.push<Address>(
'/account/addresses',
extra: {'selectMode': true, 'currentAddress': null},
);
if (result != null) {
selectedAddress.value = result;
}
},
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
border: Border.all(
color: AppColors.primaryBlue,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FaIcon(
FontAwesomeIcons.plus,
size: 14,
color: AppColors.primaryBlue,
),
SizedBox(width: AppSpacing.sm),
Text(
'Thêm địa chỉ giao hàng',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryBlue,
),
),
],
),
),
),
],
), ),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
// Pickup Date // Pickup Date
CheckoutDatePickerField( CheckoutDatePickerField(
label: 'Ngày nhận hàng mong muốn', label: 'Ngày lấy hàng',
selectedDate: selectedPickupDate, selectedDate: selectedPickupDate,
), ),
@@ -161,8 +238,8 @@ class DeliveryInformationSection extends HookWidget {
label: 'Ghi chú', label: 'Ghi chú',
controller: notesController, controller: notesController,
required: false, required: false,
maxLines: 3, maxLines: 2,
hintText: 'Ghi chú thêm cho đơn hàng (không bắt buộc)', hintText: 'Ví dụ: Thời gian yêu cầu giao hàng, lưu ý đặc biệt...',
), ),
], ],
), ),

View File

@@ -1,35 +1,30 @@
/// Invoice Section Widget /// Invoice Section Widget
/// ///
/// Optional invoice information form section. /// Optional invoice information section with address selection.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/cart/presentation/widgets/checkout_text_field.dart'; import 'package:worker/features/account/presentation/providers/address_provider.dart';
/// Invoice Section /// Invoice Section
/// ///
/// Collects invoice/VAT information when checkbox is enabled. /// Shows invoice toggle and default address when enabled.
class InvoiceSection extends HookWidget { /// Matches HTML design from checkout.html.
final ValueNotifier<bool> needsInvoice; class InvoiceSection extends HookConsumerWidget {
final TextEditingController companyNameController; const InvoiceSection({super.key, required this.needsInvoice});
final TextEditingController taxIdController;
final TextEditingController companyAddressController;
final TextEditingController companyEmailController;
const InvoiceSection({ final ValueNotifier<bool> needsInvoice;
super.key,
required this.needsInvoice,
required this.companyNameController,
required this.taxIdController,
required this.companyAddressController,
required this.companyEmailController,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
// Watch the default address
final defaultAddr = ref.watch(defaultAddressProvider);
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md), margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md), padding: const EdgeInsets.all(AppSpacing.md),
@@ -47,19 +42,18 @@ class InvoiceSection extends HookWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Invoice Checkbox // Header with Toggle
Row( Row(
children: [ children: [
Checkbox( const FaIcon(
value: needsInvoice.value, FontAwesomeIcons.fileInvoice,
onChanged: (value) { color: AppColors.primaryBlue,
needsInvoice.value = value ?? false; size: 16,
},
activeColor: AppColors.primaryBlue,
), ),
const SizedBox(width: AppSpacing.sm),
const Expanded( const Expanded(
child: Text( child: Text(
'Xuất hóa đơn VAT', 'Phát hành hóa đơn',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -67,78 +61,147 @@ class InvoiceSection extends HookWidget {
), ),
), ),
), ),
// Toggle Switch
Switch(
value: needsInvoice.value,
onChanged: (value) {
needsInvoice.value = value;
},
activeTrackColor: AppColors.primaryBlue,
),
], ],
), ),
// Invoice Fields (visible when checkbox is checked) // Invoice Information (visible when toggle is ON)
if (needsInvoice.value) ...[ if (needsInvoice.value) ...[
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
const Divider(color: Color(0xFFE0E0E0)),
// 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), const SizedBox(height: AppSpacing.md),
// Tax ID // Address Card
CheckoutTextField( if (defaultAddr != null)
label: 'Mã số thuế', InkWell(
controller: taxIdController, onTap: () {
required: true, // Navigate to addresses page for selection
keyboardType: TextInputType.number, context.push('/account/addresses');
validator: (value) {
if (needsInvoice.value && (value == null || value.isEmpty)) {
return 'Vui lòng nhập mã số thuế';
}
return null;
}, },
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
padding: const EdgeInsets.all(AppSpacing.sm),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE0E0E0)),
borderRadius: BorderRadius.circular(AppRadius.sm),
), ),
child: Row(
const SizedBox(height: AppSpacing.md), children: [
Expanded(
// Company Address child: Column(
CheckoutTextField( crossAxisAlignment: CrossAxisAlignment.start,
label: 'Địa chỉ công ty', children: [
controller: companyAddressController, // Company/Address Title
required: true, Text(
maxLines: 2, defaultAddr.addressTitle,
validator: (value) { style: const TextStyle(
if (needsInvoice.value && (value == null || value.isEmpty)) { fontSize: 14,
return 'Vui lòng nhập địa chỉ công ty'; fontWeight: FontWeight.w600,
} color: Color(0xFF212121),
return null;
},
), ),
),
const SizedBox(height: 4),
const SizedBox(height: AppSpacing.md), // Tax Code (if available)
if (defaultAddr.taxCode != null &&
defaultAddr.taxCode!.isNotEmpty) ...[
Text(
'Mã số thuế: ${defaultAddr.taxCode}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF757575),
),
),
const SizedBox(height: 2),
],
// Company Email // Phone
CheckoutTextField( Text(
label: 'Email nhận hóa đơn', 'Số điện thoại: ${defaultAddr.phone}',
controller: companyEmailController, style: const TextStyle(
required: true, fontSize: 12,
keyboardType: TextInputType.emailAddress, color: Color(0xFF757575),
validator: (value) { ),
if (needsInvoice.value && (value == null || value.isEmpty)) { ),
return 'Vui lòng nhập email'; const SizedBox(height: 2),
}
if (needsInvoice.value && // Email (if available)
!RegExp( if (defaultAddr.email != null &&
r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$', defaultAddr.email!.isNotEmpty) ...[
).hasMatch(value!)) { Text(
return 'Email không hợp lệ'; 'Email: ${defaultAddr.email}',
} style: const TextStyle(
return null; fontSize: 12,
color: Color(0xFF757575),
),
),
const SizedBox(height: 2),
],
// Address
Text(
'Địa chỉ: ${defaultAddr.fullAddress}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF757575),
),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
const FaIcon(
FontAwesomeIcons.chevronRight,
size: 14,
color: Color(0xFF9E9E9E),
),
],
),
),
)
else
// No default address - show button to add
InkWell(
onTap: () {
context.push('/account/addresses');
}, },
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
border: Border.all(
color: AppColors.primaryBlue,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FaIcon(
FontAwesomeIcons.plus,
size: 14,
color: AppColors.primaryBlue,
),
SizedBox(width: AppSpacing.sm),
Text(
'Thêm địa chỉ xuất hóa đơn',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryBlue,
),
),
],
),
),
), ),
], ],
], ],

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+5 version: 1.0.0+6
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0