create order

This commit is contained in:
Phuoc Nguyen
2025-11-21 16:50:43 +07:00
parent f2f95849d4
commit 4913a4e04b
31 changed files with 1696 additions and 187 deletions

View File

@@ -26,6 +26,8 @@ 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';
import 'package:worker/features/orders/presentation/providers/order_status_provider.dart';
import 'package:worker/features/orders/presentation/providers/payment_terms_provider.dart';
/// Checkout Page
///
@@ -51,12 +53,15 @@ class CheckoutPage extends HookConsumerWidget {
// Invoice section
final needsInvoice = useState<bool>(false);
// Payment method
final paymentMethod = useState<String>('full_payment');
// Payment method (will be set to first payment term name from API)
final paymentMethod = useState<String>('');
// Price negotiation
final needsNegotiation = useState<bool>(false);
// Watch API provider for payment terms
final paymentTermsListAsync = ref.watch(paymentTermsListProvider);
// Get CartItemData from navigation
final cartItemsData = checkoutData?['cartItems'] as List<dynamic>? ?? [];
@@ -85,7 +90,7 @@ class CheckoutPage extends HookConsumerWidget {
);
// TODO: Fetch member discount from user profile API
const memberDiscountPercent = 15.0; // Diamond tier (temporary)
const memberDiscountPercent = 0.0; // Temporarily disabled (was 15.0 for Diamond tier)
final memberDiscount = subtotal * (memberDiscountPercent / 100);
// TODO: Fetch shipping fee from API based on address
@@ -140,7 +145,78 @@ class CheckoutPage extends HookConsumerWidget {
// Payment Method Section (hidden if negotiation is checked)
if (!needsNegotiation.value)
PaymentMethodSection(paymentMethod: paymentMethod),
paymentTermsListAsync.when(
data: (paymentTerms) {
// Set default payment method to first term if not set
if (paymentMethod.value.isEmpty && paymentTerms.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
paymentMethod.value = paymentTerms.first.name;
});
}
return PaymentMethodSection(
paymentMethod: paymentMethod,
paymentTerms: paymentTerms,
);
},
loading: () => 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: const Center(
child: CircularProgressIndicator(),
),
),
error: (error, stack) => 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(
children: [
const Icon(
FontAwesomeIcons.triangleExclamation,
color: AppColors.danger,
size: 32,
),
const SizedBox(height: 12),
Text(
'Không thể tải phương thức thanh toán',
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
TextButton(
onPressed: () {
ref.invalidate(paymentTermsListProvider);
},
child: const Text('Thử lại'),
),
],
),
),
),
if (!needsNegotiation.value)
const SizedBox(height: AppSpacing.md),
@@ -164,6 +240,7 @@ class CheckoutPage extends HookConsumerWidget {
// Price Negotiation Section
PriceNegotiationSection(needsNegotiation: needsNegotiation),
const SizedBox(height: AppSpacing.md),
// Terms and Conditions
@@ -200,6 +277,8 @@ class CheckoutPage extends HookConsumerWidget {
selectedAddress: selectedAddress.value,
paymentMethod: paymentMethod.value,
total: total,
cartItems: checkoutItems,
notes: notesController.text.trim().isEmpty ? null : notesController.text.trim(),
),
const SizedBox(height: AppSpacing.lg),

View File

@@ -8,11 +8,7 @@ import 'package:worker/features/products/domain/entities/product.dart';
/// Cart Item Data
///
/// Represents a product in the cart with quantity.
class CartItemData {
final Product product;
final double quantity;
final double quantityConverted; // Rounded-up quantity for actual billing
final int boxes; // Number of tiles/boxes needed
class CartItemData { // Number of tiles/boxes needed
const CartItemData({
required this.product,
@@ -20,6 +16,10 @@ class CartItemData {
required this.quantityConverted,
required this.boxes,
});
final Product product;
final double quantity;
final double quantityConverted; // Rounded-up quantity for actual billing
final int boxes;
/// Calculate line total using CONVERTED quantity (important for accurate billing)
double get lineTotal => product.basePrice * quantityConverted;
@@ -43,19 +43,6 @@ class CartItemData {
///
/// Represents the complete state of the shopping cart.
class CartState {
final List<CartItemData> items;
final Map<String, bool> selectedItems; // productId -> isSelected
final String selectedWarehouse;
final String? discountCode;
final bool discountCodeApplied;
final String memberTier;
final double memberDiscountPercent;
final double subtotal;
final double memberDiscount;
final double shippingFee;
final double total;
final bool isLoading;
final String? errorMessage;
const CartState({
required this.items,
@@ -88,6 +75,19 @@ class CartState {
total: 0.0,
);
}
final List<CartItemData> items;
final Map<String, bool> selectedItems; // productId -> isSelected
final String selectedWarehouse;
final String? discountCode;
final bool discountCodeApplied;
final String memberTier;
final double memberDiscountPercent;
final double subtotal;
final double memberDiscount;
final double shippingFee;
final double total;
final bool isLoading;
final String? errorMessage;
bool get isEmpty => items.isEmpty;
bool get isNotEmpty => items.isNotEmpty;

View File

@@ -5,16 +5,18 @@ library;
import 'package:flutter/material.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/router/app_router.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/account/domain/entities/address.dart';
import 'package:worker/features/orders/presentation/providers/order_repository_provider.dart';
/// Checkout Submit Button
///
/// Button that changes based on negotiation checkbox state.
class CheckoutSubmitButton extends StatelessWidget {
class CheckoutSubmitButton extends HookConsumerWidget {
const CheckoutSubmitButton({
super.key,
required this.formKey,
@@ -23,6 +25,8 @@ class CheckoutSubmitButton extends StatelessWidget {
required this.selectedAddress,
required this.paymentMethod,
required this.total,
required this.cartItems,
this.notes,
});
final GlobalKey<FormState> formKey;
@@ -31,9 +35,11 @@ class CheckoutSubmitButton extends StatelessWidget {
final Address? selectedAddress;
final String paymentMethod;
final double total;
final List<Map<String, dynamic>> cartItems;
final String? notes;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
@@ -52,7 +58,7 @@ class CheckoutSubmitButton extends StatelessWidget {
}
if (formKey.currentState?.validate() ?? false) {
_handlePlaceOrder(context);
_handlePlaceOrder(context, ref);
}
},
style: ElevatedButton.styleFrom(
@@ -78,48 +84,98 @@ class CheckoutSubmitButton extends StatelessWidget {
}
/// Handle place order
void _handlePlaceOrder(BuildContext context) {
// TODO: Implement actual order placement with backend
Future<void> _handlePlaceOrder(BuildContext context, WidgetRef ref) async {
// Show loading indicator
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
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),
),
);
try {
// Prepare delivery address data
final deliveryAddressData = {
'name': selectedAddress!.name,
'phone': selectedAddress!.phone,
'street': selectedAddress!.addressLine1,
'ward': selectedAddress!.wardName ?? selectedAddress!.wardCode,
'city': selectedAddress!.cityName ?? selectedAddress!.cityCode,
};
// Navigate back after a short delay
Future.delayed(const Duration(milliseconds: 500), () {
// Prepare items data for API
// quantity = boxes/pieces (viên), quantityConverted = m²
// price = quantityConverted * basePrice
final itemsData = cartItems.map((item) {
return {
'item_id': item['sku'] as String,
'quantity': item['quantity'] as double,
'quantityConverted': item['quantityConverted'] as double,
'price': (item['quantityConverted'] as double) * (item['price'] as double),
};
}).toList();
// Call create order API
final result = await ref.read(createOrderProvider(
items: itemsData,
deliveryAddress: deliveryAddressData,
paymentMethod: paymentMethod,
needsInvoice: needsInvoice,
needsNegotiation: needsNegotiation,
notes: notes,
).future);
// Close loading dialog
if (context.mounted) {
Navigator.of(context).pop();
}
// Extract order number from response
final orderNumber = result['orderNumber'] as String? ??
result['orderId'] as String? ??
'DH${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}';
if (needsNegotiation) {
// Navigate to order success page with negotiation flag
if (context.mounted) {
context.pop();
}
});
} else {
// Generate order ID (mock - replace with actual from backend)
final orderId =
'DH${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}';
// Show order success message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Đặt hàng thành công! Chuyển đến thanh toán...'),
backgroundColor: AppColors.success,
duration: Duration(seconds: 1),
),
);
// Navigate to payment QR page after a short delay
Future.delayed(const Duration(milliseconds: 500), () {
if (context.mounted) {
context.pushNamed(
RouteNames.paymentQr,
queryParameters: {'orderId': orderId, 'amount': total.toString()},
context.pushReplacementNamed(
RouteNames.orderSuccess,
queryParameters: {
'orderNumber': orderNumber,
'total': total.toString(),
'isNegotiation': 'true',
},
);
}
});
} else {
// Navigate to payment QR page
if (context.mounted) {
context.pushReplacementNamed(
RouteNames.paymentQr,
queryParameters: {
'orderId': orderNumber,
'amount': total.toString(),
},
);
}
}
} catch (e) {
// Close loading dialog
if (context.mounted) {
Navigator.of(context).pop();
}
// Show error message
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi khi tạo đơn hàng: ${e.toString()}'),
backgroundColor: AppColors.danger,
duration: const Duration(seconds: 3),
),
);
}
}
}
}

View File

@@ -1,8 +1,6 @@
/// Payment Method Section Widget
///
/// Payment method selection with two options:
/// 1. Full payment via bank transfer
/// 2. Partial payment (>=20%, 30 day terms)
/// Payment method selection with dynamic options from API.
library;
import 'package:flutter/material.dart';
@@ -10,17 +8,51 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/orders/domain/entities/payment_term.dart';
/// Payment Method Section
///
/// Two payment options matching checkout.html design.
/// Displays payment options from API matching checkout.html design.
class PaymentMethodSection extends HookWidget {
final ValueNotifier<String> paymentMethod;
final List<PaymentTerm> paymentTerms;
const PaymentMethodSection({super.key, required this.paymentMethod});
const PaymentMethodSection({
super.key,
required this.paymentMethod,
required this.paymentTerms,
});
@override
Widget build(BuildContext context) {
// Show empty state if no payment terms available
if (paymentTerms.isEmpty) {
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: const Center(
child: Text(
'Không có phương thức thanh toán khả dụng',
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
),
),
);
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
@@ -50,105 +82,71 @@ class PaymentMethodSection extends HookWidget {
const SizedBox(height: AppSpacing.md),
// Full Payment Option
InkWell(
onTap: () => paymentMethod.value = 'full_payment',
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Radio<String>(
value: 'full_payment',
groupValue: paymentMethod.value,
onChanged: (value) {
paymentMethod.value = value!;
},
activeColor: AppColors.primaryBlue,
),
const SizedBox(width: 12),
const Icon(
FontAwesomeIcons.buildingColumns,
color: AppColors.grey500,
size: 24,
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
// Dynamic Payment Options from API
...paymentTerms.asMap().entries.map((entry) {
final index = entry.key;
final term = entry.value;
// Choose icon based on payment term name
IconData icon = FontAwesomeIcons.buildingColumns;
if (term.name.toLowerCase().contains('trả trước') ||
term.name.toLowerCase().contains('một phần')) {
icon = FontAwesomeIcons.creditCard;
}
return Column(
children: [
if (index > 0) const Divider(height: 1),
InkWell(
onTap: () => paymentMethod.value = term.name,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Text(
'Thanh toán hoàn toàn',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
Radio<String>(
value: term.name,
groupValue: paymentMethod.value,
onChanged: (value) {
paymentMethod.value = value!;
},
activeColor: AppColors.primaryBlue,
),
SizedBox(height: 4),
Text(
'Thanh toán qua tài khoản ngân hàng',
style: TextStyle(
fontSize: 13,
color: AppColors.grey500,
const SizedBox(width: 12),
Icon(
icon,
color: AppColors.grey500,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
term.name,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
term.customDescription,
style: const TextStyle(
fontSize: 13,
color: AppColors.grey500,
),
),
],
),
),
],
),
),
],
),
),
),
const Divider(height: 1),
// Partial Payment Option
InkWell(
onTap: () => paymentMethod.value = 'partial_payment',
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Radio<String>(
value: 'partial_payment',
groupValue: paymentMethod.value,
onChanged: (value) {
paymentMethod.value = value!;
},
activeColor: AppColors.primaryBlue,
),
const SizedBox(width: 12),
const Icon(
FontAwesomeIcons.creditCard,
color: AppColors.grey500,
size: 24,
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Thanh toán một phần',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 4),
Text(
'Trả trước(≥20%), còn lại thanh toán trong vòng 30 ngày',
style: TextStyle(
fontSize: 13,
color: AppColors.grey500,
),
),
],
),
),
],
),
),
),
),
],
);
}),
],
),
);