From 4913a4e04b0c895d74d619c24f7924797d919c3b Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Fri, 21 Nov 2025 16:50:43 +0700 Subject: [PATCH] create order --- docs/order.sh | 90 ++++++ html/order-success.html | 6 +- html/payment-qr.html | 9 +- lib/core/constants/api_constants.dart | 13 +- lib/core/router/app_router.dart | 24 ++ .../auth/presentation/pages/login_page.dart | 2 +- .../presentation/pages/checkout_page.dart | 87 +++++- .../presentation/providers/cart_state.dart | 36 +-- .../widgets/checkout_submit_button.dart | 136 ++++++--- .../widgets/payment_method_section.dart | 192 ++++++------ .../invoices_local_datasource.dart | 2 +- .../datasources/order_remote_datasource.dart | 157 ++++++++++ .../datasources/orders_local_datasource.dart | 2 +- .../data/models/order_status_model.dart | 65 ++++ .../data/models/payment_term_model.dart | 53 ++++ .../repositories/order_repository_impl.dart | 59 ++++ .../orders/domain/entities/order_status.dart | 31 ++ .../orders/domain/entities/payment_term.dart | 23 ++ .../domain/repositories/order_repository.dart | 26 ++ .../pages/order_success_page.dart | 280 ++++++++++++++++++ .../providers/order_data_providers.dart | 26 ++ .../providers/order_data_providers.g.dart | 100 +++++++ .../providers/order_repository_provider.dart | 44 +++ .../order_repository_provider.g.dart | 201 +++++++++++++ .../providers/order_status_provider.dart | 20 ++ .../providers/order_status_provider.g.dart | 64 ++++ .../providers/payment_terms_provider.dart | 20 ++ .../providers/payment_terms_provider.g.dart | 64 ++++ .../presentation/widgets/order_card.dart | 1 - .../pages/product_detail_page.dart | 2 + .../presentation/pages/products_page.dart | 48 ++- 31 files changed, 1696 insertions(+), 187 deletions(-) create mode 100644 docs/order.sh create mode 100644 lib/features/orders/data/datasources/order_remote_datasource.dart create mode 100644 lib/features/orders/data/models/order_status_model.dart create mode 100644 lib/features/orders/data/models/payment_term_model.dart create mode 100644 lib/features/orders/data/repositories/order_repository_impl.dart create mode 100644 lib/features/orders/domain/entities/order_status.dart create mode 100644 lib/features/orders/domain/entities/payment_term.dart create mode 100644 lib/features/orders/domain/repositories/order_repository.dart create mode 100644 lib/features/orders/presentation/pages/order_success_page.dart create mode 100644 lib/features/orders/presentation/providers/order_data_providers.dart create mode 100644 lib/features/orders/presentation/providers/order_data_providers.g.dart create mode 100644 lib/features/orders/presentation/providers/order_repository_provider.dart create mode 100644 lib/features/orders/presentation/providers/order_repository_provider.g.dart create mode 100644 lib/features/orders/presentation/providers/order_status_provider.dart create mode 100644 lib/features/orders/presentation/providers/order_status_provider.g.dart create mode 100644 lib/features/orders/presentation/providers/payment_terms_provider.dart create mode 100644 lib/features/orders/presentation/providers/payment_terms_provider.g.dart diff --git a/docs/order.sh b/docs/order.sh new file mode 100644 index 0000000..88ef509 --- /dev/null +++ b/docs/order.sh @@ -0,0 +1,90 @@ + +#Get list of order status +curl --location --request POST 'https://land.dbiz.com//api/method/building_material.building_material.api.sales_order.get_order_status_list' \ +--header 'Cookie: sid=a98c0b426abd8af3b0fd92407ef96937acda888a9a63bf3c580447d4; full_name=Hsadqdqwed; sid=42d89a7465571e04e0ee47a5bb1dd73563ff4f30ef9f7370ed490275; system_user=no; user_id=123%40gmail.com; user_image=/files/avatar_0987654321_1763631288.jpg' \ +--header 'X-Frappe-Csrf-Token: a2bc5e9342441ff895ad2781e99a4c3fae4cad1250ae40c51f90067a' \ +--header 'Content-Type: application/json' \ +--data '' + +#Response list of order status +{ + "message": [ + { + "status": "Pending approval", + "label": "Chờ phê duyệt", + "color": "Warning", + "index": 1 + }, + { + "status": "Processing", + "label": "Đang xử lý", + "color": "Warning", + "index": 2 + }, + { + "status": "Completed", + "label": "Hoàn thành", + "color": "Success", + "index": 3 + }, + { + "status": "Rejected", + "label": "Từ chối", + "color": "Danger", + "index": 4 + }, + { + "status": "Cancelled", + "label": "HỦY BỎ", + "color": "Danger", + "index": 5 + } + ] +} + +#get payment list +curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \ +--header 'X-Frappe-Csrf-Token: a2bc5e9342441ff895ad2781e99a4c3fae4cad1250ae40c51f90067a' \ +--header 'Cookie: sid=a98c0b426abd8af3b0fd92407ef96937acda888a9a63bf3c580447d4; full_name=phuoc; sid=a98c0b426abd8af3b0fd92407ef96937acda888a9a63bf3c580447d4; system_user=no; user_id=vodanh.2901%40gmail.com; user_image=https%3A//secure.gravatar.com/avatar/753a0e2601b9bd87aed417e2ad123bf8%3Fd%3D404%26s%3D200' \ +--header 'Content-Type: application/json' \ +--data '{ + "doctype": "Payment Terms Template", + "fields": ["name","custom_description"], + "limit_page_length": 0 +}' + +#response payment list +{ + "message": [ + { + "name": "Thanh toán hoàn toàn", + "custom_description": "Thanh toán ngay được chiết khấu 2%" + }, + { + "name": "Thanh toán trả trước", + "custom_description": "Trả trước (≥20%), còn lại thanh toán trong vòng 30 ngày" + } + ] +} + +#create order +curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.sales_order.save' \ +--header 'Cookie: sid=a98c0b426abd8af3b0fd92407ef96937acda888a9a63bf3c580447d4; full_name=phuoc; sid=c0f46dc2ed23d58c013daa7d1813b36caf04555472b792cdb74e0d61; system_user=no; user_id=vodanh.2901%40gmail.com; user_image=https%3A//secure.gravatar.com/avatar/753a0e2601b9bd87aed417e2ad123bf8%3Fd%3D404%26s%3D200' \ +--header 'X-Frappe-Csrf-Token: a2bc5e9342441ff895ad2781e99a4c3fae4cad1250ae40c51f90067a' \ +--header 'Content-Type: application/json' \ +--data '{ + "transaction_date": "2025-11-20", // Ngày tạo + "delivery_date": "2025-11-20", // Ngày dự kiến giao + "shipping_address_name": "Lam Address-Billing", + "customer_address": "Lam Address-Billing", + "description": "Order description", // Ghi chú + "payment_terms" : "Thanh toán hoàn toàn", // Lấy name từ GET PAYMENT TERM + "items": [ + { + "item_id": "HOA E02", + "qty_entered": 2, // SỐ lượng User tự nhập + "primary_qty" : 2.56, // SỐ lượng sau khi quy đổi + "price_entered": 10000 // Đơn giá + } + ] + }' \ No newline at end of file diff --git a/html/order-success.html b/html/order-success.html index 58b9948..04a4205 100644 --- a/html/order-success.html +++ b/html/order-success.html @@ -16,7 +16,7 @@ -

Đặt hàng thành công!

+

Tạo đơn hàng thành công!

Cảm ơn bạn đã đặt hàng. Chúng tôi sẽ liên hệ xác nhận trong vòng 24 giờ.

@@ -46,7 +46,7 @@ -
+ diff --git a/html/payment-qr.html b/html/payment-qr.html index be3a8bf..508bef6 100644 --- a/html/payment-qr.html +++ b/html/payment-qr.html @@ -122,7 +122,7 @@
Nội dung: - DH001234 La Nguyen Quynh + DH001234 @@ -139,12 +139,15 @@ diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index 9554687..f42f1d0 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -211,10 +211,15 @@ class ApiConstants { // Order Endpoints // ============================================================================ - /// Create new order - /// POST /orders - /// Body: { "items": [...], "deliveryAddress": {...}, "paymentMethod": "..." } - static const String createOrder = '/orders'; + /// Get order status list (requires sid and csrf_token) + /// POST /api/method/building_material.building_material.api.sales_order.get_order_status_list + /// Returns: { "message": [{ "status": "...", "label": "...", "color": "...", "index": 0 }] } + static const String getOrderStatusList = '/building_material.building_material.api.sales_order.get_order_status_list'; + + /// Create new order (requires sid and csrf_token) + /// POST /api/method/building_material.building_material.api.sales_order.save + /// Body: { "transaction_date": "...", "delivery_date": "...", "items": [...], ... } + static const String createOrder = '/building_material.building_material.api.sales_order.save'; /// Get user's orders /// GET /orders?status={status}&page={page}&limit={limit} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 51dbc44..57a32e8 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -32,6 +32,7 @@ import 'package:worker/features/main/presentation/pages/main_scaffold.dart'; import 'package:worker/features/news/presentation/pages/news_detail_page.dart'; import 'package:worker/features/news/presentation/pages/news_list_page.dart'; import 'package:worker/features/orders/presentation/pages/order_detail_page.dart'; +import 'package:worker/features/orders/presentation/pages/order_success_page.dart'; import 'package:worker/features/orders/presentation/pages/orders_page.dart'; import 'package:worker/features/orders/presentation/pages/payment_detail_page.dart'; import 'package:worker/features/orders/presentation/pages/payment_qr_page.dart'; @@ -327,6 +328,29 @@ final routerProvider = Provider((ref) { }, ), + // Order Success Route + GoRoute( + path: RouteNames.orderSuccess, + name: RouteNames.orderSuccess, + pageBuilder: (context, state) { + final orderNumber = state.uri.queryParameters['orderNumber'] ?? ''; + final totalStr = state.uri.queryParameters['total']; + final total = totalStr != null ? double.tryParse(totalStr) : null; + final paymentMethod = state.uri.queryParameters['paymentMethod']; + final isNegotiationStr = state.uri.queryParameters['isNegotiation']; + final isNegotiation = isNegotiationStr == 'true'; + return MaterialPage( + key: state.pageKey, + child: OrderSuccessPage( + orderNumber: orderNumber, + total: total, + paymentMethod: paymentMethod, + isNegotiation: isNegotiation, + ), + ); + }, + ), + // Quotes Route GoRoute( path: RouteNames.quotes, diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart index a248789..edeb69d 100644 --- a/lib/features/auth/presentation/pages/login_page.dart +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -42,7 +42,7 @@ class _LoginPageState extends ConsumerState { final _formKey = GlobalKey(); // Controllers - final _phoneController = TextEditingController(text: "0978113710"); + final _phoneController = TextEditingController(text: "0986788766"); final _passwordController = TextEditingController(text: "123456"); // Focus nodes diff --git a/lib/features/cart/presentation/pages/checkout_page.dart b/lib/features/cart/presentation/pages/checkout_page.dart index 54bffd8..1ec1954 100644 --- a/lib/features/cart/presentation/pages/checkout_page.dart +++ b/lib/features/cart/presentation/pages/checkout_page.dart @@ -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(false); - // Payment method - final paymentMethod = useState('full_payment'); + // Payment method (will be set to first payment term name from API) + final paymentMethod = useState(''); // Price negotiation final needsNegotiation = useState(false); + // Watch API provider for payment terms + final paymentTermsListAsync = ref.watch(paymentTermsListProvider); + // Get CartItemData from navigation final cartItemsData = checkoutData?['cartItems'] as List? ?? []; @@ -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), diff --git a/lib/features/cart/presentation/providers/cart_state.dart b/lib/features/cart/presentation/providers/cart_state.dart index a49e858..74b6feb 100644 --- a/lib/features/cart/presentation/providers/cart_state.dart +++ b/lib/features/cart/presentation/providers/cart_state.dart @@ -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 items; - final Map 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 items; + final Map 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; diff --git a/lib/features/cart/presentation/widgets/checkout_submit_button.dart b/lib/features/cart/presentation/widgets/checkout_submit_button.dart index 2f1181c..11236de 100644 --- a/lib/features/cart/presentation/widgets/checkout_submit_button.dart +++ b/lib/features/cart/presentation/widgets/checkout_submit_button.dart @@ -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 formKey; @@ -31,9 +35,11 @@ class CheckoutSubmitButton extends StatelessWidget { final Address? selectedAddress; final String paymentMethod; final double total; + final List> 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 _handlePlaceOrder(BuildContext context, WidgetRef ref) async { + // Show loading indicator + showDialog( + 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), + ), + ); + } } } } diff --git a/lib/features/cart/presentation/widgets/payment_method_section.dart b/lib/features/cart/presentation/widgets/payment_method_section.dart index 1b542c7..3d82942 100644 --- a/lib/features/cart/presentation/widgets/payment_method_section.dart +++ b/lib/features/cart/presentation/widgets/payment_method_section.dart @@ -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 paymentMethod; + final List 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( - 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( + 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( - 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, - ), - ), - ], - ), - ), - ], - ), - ), - ), + ), + ], + ); + }), ], ), ); diff --git a/lib/features/orders/data/datasources/invoices_local_datasource.dart b/lib/features/orders/data/datasources/invoices_local_datasource.dart index 7784b1a..a409199 100644 --- a/lib/features/orders/data/datasources/invoices_local_datasource.dart +++ b/lib/features/orders/data/datasources/invoices_local_datasource.dart @@ -23,7 +23,7 @@ class InvoicesLocalDataSource { throw Exception('Invalid JSON format: expected List'); } - final invoices = (decoded as List) + final invoices = decoded .map((json) => InvoiceModel.fromJson(json as Map)) .toList(); diff --git a/lib/features/orders/data/datasources/order_remote_datasource.dart b/lib/features/orders/data/datasources/order_remote_datasource.dart new file mode 100644 index 0000000..811218f --- /dev/null +++ b/lib/features/orders/data/datasources/order_remote_datasource.dart @@ -0,0 +1,157 @@ +/// Order Remote Data Source +/// +/// Handles API calls for order-related data. +library; + +import 'package:worker/core/constants/api_constants.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/features/orders/data/models/order_status_model.dart'; +import 'package:worker/features/orders/data/models/payment_term_model.dart'; + +/// Order Remote Data Source +class OrderRemoteDataSource { + const OrderRemoteDataSource(this._dioClient); + + final DioClient _dioClient; + + /// Get order status list + /// + /// Calls: POST /api/method/building_material.building_material.api.sales_order.get_order_status_list + /// Returns: List of order statuses with labels and colors + Future> getOrderStatusList() async { + try { + final response = await _dioClient.post>( + '${ApiConstants.frappeApiMethod}${ApiConstants.getOrderStatusList}', + data: {}, + ); + + final data = response.data; + if (data == null) { + throw Exception('No data received from getOrderStatusList API'); + } + + // API returns: { "message": [...] } + final message = data['message']; + if (message == null) { + throw Exception('No message field in getOrderStatusList response'); + } + + final List statusList = message as List; + return statusList + .map((json) => OrderStatusModel.fromJson(json as Map)) + .toList(); + } catch (e) { + throw Exception('Failed to get order status list: $e'); + } + } + + /// Get payment terms list + /// + /// Calls: POST /api/method/frappe.client.get_list + /// Body: { "doctype": "Payment Terms Template", "fields": ["name","custom_description"], "limit_page_length": 0 } + /// Returns: List of payment terms with names and descriptions + Future> getPaymentTermsList() async { + try { + final response = await _dioClient.post>( + '${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}', + data: { + 'doctype': 'Payment Terms Template', + 'fields': ['name', 'custom_description'], + 'limit_page_length': 0, + }, + ); + + final data = response.data; + if (data == null) { + throw Exception('No data received from getPaymentTermsList API'); + } + + // API returns: { "message": [...] } + final message = data['message']; + if (message == null) { + throw Exception('No message field in getPaymentTermsList response'); + } + + final List termsList = message as List; + return termsList + .map((json) => PaymentTermModel.fromJson(json as Map)) + .toList(); + } catch (e) { + throw Exception('Failed to get payment terms list: $e'); + } + } + + /// Create new order + /// + /// Calls: POST /api/method/building_material.building_material.api.sales_order.save + /// Body: { + /// "transaction_date": "2025-11-20", + /// "delivery_date": "2025-11-20", + /// "shipping_address_name": "...", + /// "customer_address": "...", + /// "description": "...", + /// "payment_terms": "...", + /// "items": [{"item_id": "...", "qty_entered": 0, "primary_qty": 0, "price_entered": 0}] + /// } + /// Returns: { "message": { "name": "SAL-ORD-2025-00001", ... } } + Future> createOrder({ + required List> items, + required Map deliveryAddress, + required String paymentMethod, + bool needsInvoice = false, + bool needsNegotiation = false, + String? notes, + }) async { + try { + // Get current date for transaction and delivery + final now = DateTime.now(); + final dateStr = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + + // Format items for Frappe API + final formattedItems = items.map((item) { + return { + 'item_id': item['item_id'], + 'qty_entered': item['quantity'], // boxes/pieces (viên) + 'primary_qty': item['quantityConverted'], // m² + 'price_entered': item['price'], + }; + }).toList(); + + // Prepare request body in Frappe format + final requestBody = { + 'transaction_date': dateStr, + 'delivery_date': dateStr, + 'shipping_address_name': deliveryAddress['name'] ?? '', + 'customer_address': deliveryAddress['name'] ?? '', + 'description': notes ?? 'Order from mobile app', + 'payment_terms': paymentMethod, + 'items': formattedItems, + }; + + final response = await _dioClient.post>( + '${ApiConstants.frappeApiMethod}${ApiConstants.createOrder}', + data: requestBody, + ); + + final data = response.data; + if (data == null) { + throw Exception('No data received from createOrder API'); + } + + // Extract order info from Frappe response + final message = data['message'] as Map?; + if (message == null) { + throw Exception('No message field in createOrder response'); + } + + // Return standardized response + return { + 'orderId': message['name'] ?? '', + 'orderNumber': message['name'] ?? '', + 'fullResponse': message, + }; + } catch (e) { + throw Exception('Failed to create order: $e'); + } + } +} diff --git a/lib/features/orders/data/datasources/orders_local_datasource.dart b/lib/features/orders/data/datasources/orders_local_datasource.dart index 012fa4e..31d6220 100644 --- a/lib/features/orders/data/datasources/orders_local_datasource.dart +++ b/lib/features/orders/data/datasources/orders_local_datasource.dart @@ -24,7 +24,7 @@ class OrdersLocalDataSource { throw Exception('Invalid JSON format: expected List'); } - final orders = (decoded as List) + final orders = decoded .map((json) => OrderModel.fromJson(json as Map)) .toList(); diff --git a/lib/features/orders/data/models/order_status_model.dart b/lib/features/orders/data/models/order_status_model.dart new file mode 100644 index 0000000..fff60ba --- /dev/null +++ b/lib/features/orders/data/models/order_status_model.dart @@ -0,0 +1,65 @@ +/// Order Status Model +/// +/// Data model for order status from API responses. +library; + +import 'package:equatable/equatable.dart'; +import 'package:worker/features/orders/domain/entities/order_status.dart'; + +/// Order Status Model +class OrderStatusModel extends Equatable { + final String status; + final String label; + final String color; + final int index; + + const OrderStatusModel({ + required this.status, + required this.label, + required this.color, + required this.index, + }); + + /// Create from JSON + factory OrderStatusModel.fromJson(Map json) { + return OrderStatusModel( + status: json['status'] as String, + label: json['label'] as String, + color: json['color'] as String, + index: json['index'] as int, + ); + } + + /// Convert to JSON + Map toJson() { + return { + 'status': status, + 'label': label, + 'color': color, + 'index': index, + }; + } + + /// Convert to entity + OrderStatus toEntity() { + return OrderStatus( + status: status, + label: label, + color: color, + index: index, + ); + } + + /// Create from entity + factory OrderStatusModel.fromEntity(OrderStatus entity) { + return OrderStatusModel( + status: entity.status, + label: entity.label, + color: entity.color, + index: entity.index, + ); + } + + @override + List get props => [status, label, color, index]; +} diff --git a/lib/features/orders/data/models/payment_term_model.dart b/lib/features/orders/data/models/payment_term_model.dart new file mode 100644 index 0000000..61bc23c --- /dev/null +++ b/lib/features/orders/data/models/payment_term_model.dart @@ -0,0 +1,53 @@ +/// Payment Term Model +/// +/// Data model for payment term from API responses. +library; + +import 'package:equatable/equatable.dart'; +import 'package:worker/features/orders/domain/entities/payment_term.dart'; + +/// Payment Term Model +class PaymentTermModel extends Equatable { + final String name; + final String? customDescription; + + const PaymentTermModel({ + required this.name, + this.customDescription, + }); + + /// Create from JSON + factory PaymentTermModel.fromJson(Map json) { + return PaymentTermModel( + name: json['name'] as String, + customDescription: json['custom_description'] as String?, + ); + } + + /// Convert to JSON + Map toJson() { + return { + 'name': name, + 'custom_description': customDescription, + }; + } + + /// Convert to entity + PaymentTerm toEntity() { + return PaymentTerm( + name: name, + customDescription: customDescription ?? '', + ); + } + + /// Create from entity + factory PaymentTermModel.fromEntity(PaymentTerm entity) { + return PaymentTermModel( + name: entity.name, + customDescription: entity.customDescription, + ); + } + + @override + List get props => [name, customDescription]; +} diff --git a/lib/features/orders/data/repositories/order_repository_impl.dart b/lib/features/orders/data/repositories/order_repository_impl.dart new file mode 100644 index 0000000..4f9112a --- /dev/null +++ b/lib/features/orders/data/repositories/order_repository_impl.dart @@ -0,0 +1,59 @@ +/// Order Repository Implementation +/// +/// Implements the order repository interface. +library; + +import 'package:worker/features/orders/data/datasources/order_remote_datasource.dart'; +import 'package:worker/features/orders/domain/entities/order_status.dart'; +import 'package:worker/features/orders/domain/entities/payment_term.dart'; +import 'package:worker/features/orders/domain/repositories/order_repository.dart'; + +/// Order Repository Implementation +class OrderRepositoryImpl implements OrderRepository { + const OrderRepositoryImpl(this._remoteDataSource); + + final OrderRemoteDataSource _remoteDataSource; + + @override + Future> getOrderStatusList() async { + try { + final models = await _remoteDataSource.getOrderStatusList(); + return models.map((model) => model.toEntity()).toList(); + } catch (e) { + throw Exception('Failed to get order status list: $e'); + } + } + + @override + Future> getPaymentTermsList() async { + try { + final models = await _remoteDataSource.getPaymentTermsList(); + return models.map((model) => model.toEntity()).toList(); + } catch (e) { + throw Exception('Failed to get payment terms list: $e'); + } + } + + @override + Future> createOrder({ + required List> items, + required Map deliveryAddress, + required String paymentMethod, + bool needsInvoice = false, + bool needsNegotiation = false, + String? notes, + }) async { + try { + return await _remoteDataSource.createOrder( + items: items, + deliveryAddress: deliveryAddress, + paymentMethod: paymentMethod, + needsInvoice: needsInvoice, + needsNegotiation: needsNegotiation, + notes: notes, + ); + } catch (e) { + throw Exception('Failed to create order: $e'); + } + } +} diff --git a/lib/features/orders/domain/entities/order_status.dart b/lib/features/orders/domain/entities/order_status.dart new file mode 100644 index 0000000..5ff6a46 --- /dev/null +++ b/lib/features/orders/domain/entities/order_status.dart @@ -0,0 +1,31 @@ +/// Order Status Entity +/// +/// Represents an order status option from the API. +library; + +import 'package:equatable/equatable.dart'; + +/// Order Status Entity +class OrderStatus extends Equatable { + /// Status value (e.g., "Pending approval", "Processing", "Completed") + final String status; + + /// Vietnamese label (e.g., "Chờ phê duyệt", "Đang xử lý", "Hoàn thành") + final String label; + + /// Color indicator (e.g., "Warning", "Success", "Danger") + final String color; + + /// Display order index + final int index; + + const OrderStatus({ + required this.status, + required this.label, + required this.color, + required this.index, + }); + + @override + List get props => [status, label, color, index]; +} diff --git a/lib/features/orders/domain/entities/payment_term.dart b/lib/features/orders/domain/entities/payment_term.dart new file mode 100644 index 0000000..85445e8 --- /dev/null +++ b/lib/features/orders/domain/entities/payment_term.dart @@ -0,0 +1,23 @@ +/// Payment Term Entity +/// +/// Represents a payment term template option from the API. +library; + +import 'package:equatable/equatable.dart'; + +/// Payment Term Entity +class PaymentTerm extends Equatable { + /// Payment term name (e.g., "Thanh toán hoàn toàn", "Thanh toán trả trước") + final String name; + + /// Custom description (e.g., "Thanh toán ngay được chiết khấu 2%") + final String customDescription; + + const PaymentTerm({ + required this.name, + required this.customDescription, + }); + + @override + List get props => [name, customDescription]; +} diff --git a/lib/features/orders/domain/repositories/order_repository.dart b/lib/features/orders/domain/repositories/order_repository.dart new file mode 100644 index 0000000..03b6b53 --- /dev/null +++ b/lib/features/orders/domain/repositories/order_repository.dart @@ -0,0 +1,26 @@ +/// Order Repository Interface +/// +/// Defines the contract for order-related data operations. +library; + +import 'package:worker/features/orders/domain/entities/order_status.dart'; +import 'package:worker/features/orders/domain/entities/payment_term.dart'; + +/// Order Repository Interface +abstract class OrderRepository { + /// Get list of available order statuses + Future> getOrderStatusList(); + + /// Get list of available payment terms + Future> getPaymentTermsList(); + + /// Create new order + Future> createOrder({ + required List> items, + required Map deliveryAddress, + required String paymentMethod, + bool needsInvoice = false, + bool needsNegotiation = false, + String? notes, + }); +} diff --git a/lib/features/orders/presentation/pages/order_success_page.dart b/lib/features/orders/presentation/pages/order_success_page.dart new file mode 100644 index 0000000..982ccc5 --- /dev/null +++ b/lib/features/orders/presentation/pages/order_success_page.dart @@ -0,0 +1,280 @@ +/// Order Success Page +/// +/// Displays order confirmation after successful order placement. +/// Features: +/// - Success icon and message +/// - Order information (order number, date, total, payment method, status) +/// - Different message for negotiation requests +/// - Navigation to order details or home +library; + +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/router/app_router.dart'; +import 'package:worker/core/theme/colors.dart'; + +/// Order Success Page +class OrderSuccessPage extends StatelessWidget { + const OrderSuccessPage({ + super.key, + required this.orderNumber, + this.total, + this.paymentMethod, + this.isNegotiation = false, + }); + + final String orderNumber; + final double? total; + final String? paymentMethod; + final bool isNegotiation; + + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + final dateFormat = DateFormat('dd/MM/yyyy HH:mm'); + + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Success Icon + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: AppColors.success.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: const Icon( + FontAwesomeIcons.check, + color: AppColors.success, + size: 40, + ), + ), + + const SizedBox(height: AppSpacing.lg), + + // Success Title + Text( + isNegotiation + ? 'Gửi yêu cầu thành công!' + : 'Tạo đơn hàng thành công!', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Color(0xFF212121), + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: AppSpacing.sm), + + // Success Message + Text( + isNegotiation + ? 'Chúng tôi sẽ liên hệ với bạn để đàm phán giá trong vòng 24 giờ.' + : 'Cảm ơn bạn đã đặt hàng. Chúng tôi sẽ liên hệ xác nhận trong vòng 24 giờ.', + style: const TextStyle( + fontSize: 14, + color: AppColors.grey500, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: AppSpacing.xl), + + // Order Info Card + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: const Color(0xFFF4F6F8), + borderRadius: BorderRadius.circular(AppRadius.card), + ), + child: Column( + children: [ + // Order Number + Column( + children: [ + const Text( + 'Mã đơn hàng', + style: TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + ), + const SizedBox(height: 4), + Text( + orderNumber, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.primaryBlue, + ), + ), + ], + ), + + const SizedBox(height: AppSpacing.md), + + // Order Date + _buildInfoRow( + 'Ngày đặt', + dateFormat.format(now), + ), + + const SizedBox(height: AppSpacing.sm), + + // Total Amount + if (total != null) + _buildInfoRow( + 'Tổng tiền', + _formatCurrency(total!), + valueStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF212121), + ), + ), + + if (total != null) const SizedBox(height: AppSpacing.sm), + + // Payment Method + if (paymentMethod != null && !isNegotiation) + _buildInfoRow( + 'Phương thức thanh toán', + paymentMethod!, + ), + + if (paymentMethod != null && !isNegotiation) + const SizedBox(height: AppSpacing.sm), + + // Status + _buildInfoRow( + 'Trạng thái', + isNegotiation ? 'Chờ đàm phán' : 'Chờ xác nhận', + valueStyle: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: isNegotiation + ? AppColors.warning + : AppColors.warning, + ), + ), + ], + ), + ), + + const SizedBox(height: AppSpacing.xl), + + // View Order Details Button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + // Navigate to order details page + context.pushReplacementNamed( + RouteNames.orderDetail, + pathParameters: {'orderId': orderNumber}, + ); + }, + icon: const FaIcon(FontAwesomeIcons.eye, size: 18), + label: const Text( + 'Xem chi tiết đơn hàng', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + ), + ), + + const SizedBox(height: AppSpacing.sm), + + // Back to Home Button + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + // Navigate to home page + context.goNamed(RouteNames.home); + }, + icon: const FaIcon(FontAwesomeIcons.house, size: 18), + label: const Text( + 'Quay về trang chủ', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.grey900, + side: BorderSide( + color: AppColors.grey100, + width: 1.5, + ), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + /// Build info row + Widget _buildInfoRow( + String label, + String value, { + TextStyle? valueStyle, + }) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + color: AppColors.grey500, + ), + ), + Text( + value, + style: valueStyle ?? + const TextStyle( + fontSize: 14, + color: 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/orders/presentation/providers/order_data_providers.dart b/lib/features/orders/presentation/providers/order_data_providers.dart new file mode 100644 index 0000000..1ca7334 --- /dev/null +++ b/lib/features/orders/presentation/providers/order_data_providers.dart @@ -0,0 +1,26 @@ +/// Order Data Providers +/// +/// Riverpod providers for order data sources and repositories. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/features/orders/data/datasources/order_remote_datasource.dart'; +import 'package:worker/features/orders/data/repositories/order_repository_impl.dart'; +import 'package:worker/features/orders/domain/repositories/order_repository.dart'; + +part 'order_data_providers.g.dart'; + +/// Provider for Order Remote Data Source +@riverpod +Future orderRemoteDataSource(Ref ref) async { + final dioClient = await ref.watch(dioClientProvider.future); + return OrderRemoteDataSource(dioClient); +} + +/// Provider for Order Repository +@riverpod +Future orderRepository(Ref ref) async { + final remoteDataSource = await ref.watch(orderRemoteDataSourceProvider.future); + return OrderRepositoryImpl(remoteDataSource); +} diff --git a/lib/features/orders/presentation/providers/order_data_providers.g.dart b/lib/features/orders/presentation/providers/order_data_providers.g.dart new file mode 100644 index 0000000..f5fa892 --- /dev/null +++ b/lib/features/orders/presentation/providers/order_data_providers.g.dart @@ -0,0 +1,100 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'order_data_providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Provider for Order Remote Data Source + +@ProviderFor(orderRemoteDataSource) +const orderRemoteDataSourceProvider = OrderRemoteDataSourceProvider._(); + +/// Provider for Order Remote Data Source + +final class OrderRemoteDataSourceProvider + extends + $FunctionalProvider< + AsyncValue, + OrderRemoteDataSource, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// Provider for Order Remote Data Source + const OrderRemoteDataSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'orderRemoteDataSourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$orderRemoteDataSourceHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return orderRemoteDataSource(ref); + } +} + +String _$orderRemoteDataSourceHash() => + r'f4e14afbd8ae9e4348cba8f9a983ff9cb5e2a4c7'; + +/// Provider for Order Repository + +@ProviderFor(orderRepository) +const orderRepositoryProvider = OrderRepositoryProvider._(); + +/// Provider for Order Repository + +final class OrderRepositoryProvider + extends + $FunctionalProvider< + AsyncValue, + OrderRepository, + FutureOr + > + with $FutureModifier, $FutureProvider { + /// Provider for Order Repository + const OrderRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'orderRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$orderRepositoryHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return orderRepository(ref); + } +} + +String _$orderRepositoryHash() => r'985408a6667ab31427524f9b1981287c28f4f221'; diff --git a/lib/features/orders/presentation/providers/order_repository_provider.dart b/lib/features/orders/presentation/providers/order_repository_provider.dart new file mode 100644 index 0000000..6c0daea --- /dev/null +++ b/lib/features/orders/presentation/providers/order_repository_provider.dart @@ -0,0 +1,44 @@ +/// Order Repository Provider +/// +/// Provides the order repository instance and related providers. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/features/orders/data/datasources/order_remote_datasource.dart'; +import 'package:worker/features/orders/data/repositories/order_repository_impl.dart'; +import 'package:worker/features/orders/domain/repositories/order_repository.dart'; + +part 'order_repository_provider.g.dart'; + +/// Order Repository Provider +@riverpod +Future orderRepository(Ref ref) async { + final dioClient = await ref.watch(dioClientProvider.future); + final remoteDataSource = OrderRemoteDataSource(dioClient); + return OrderRepositoryImpl(remoteDataSource); +} + +/// Create Order Provider +/// +/// Creates a new order with the given parameters. +@riverpod +Future> createOrder( + Ref ref, { + required List> items, + required Map deliveryAddress, + required String paymentMethod, + bool needsInvoice = false, + bool needsNegotiation = false, + String? notes, +}) async { + final repository = await ref.watch(orderRepositoryProvider.future); + return await repository.createOrder( + items: items, + deliveryAddress: deliveryAddress, + paymentMethod: paymentMethod, + needsInvoice: needsInvoice, + needsNegotiation: needsNegotiation, + notes: notes, + ); +} diff --git a/lib/features/orders/presentation/providers/order_repository_provider.g.dart b/lib/features/orders/presentation/providers/order_repository_provider.g.dart new file mode 100644 index 0000000..56761a8 --- /dev/null +++ b/lib/features/orders/presentation/providers/order_repository_provider.g.dart @@ -0,0 +1,201 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'order_repository_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Order Repository Provider + +@ProviderFor(orderRepository) +const orderRepositoryProvider = OrderRepositoryProvider._(); + +/// Order Repository Provider + +final class OrderRepositoryProvider + extends + $FunctionalProvider< + AsyncValue, + OrderRepository, + FutureOr + > + with $FutureModifier, $FutureProvider { + /// Order Repository Provider + const OrderRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'orderRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$orderRepositoryHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return orderRepository(ref); + } +} + +String _$orderRepositoryHash() => r'15efafcf3b545ea52fdc8d0acbd8192ba8f41546'; + +/// Create Order Provider +/// +/// Creates a new order with the given parameters. + +@ProviderFor(createOrder) +const createOrderProvider = CreateOrderFamily._(); + +/// Create Order Provider +/// +/// Creates a new order with the given parameters. + +final class CreateOrderProvider + extends + $FunctionalProvider< + AsyncValue>, + Map, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// Create Order Provider + /// + /// Creates a new order with the given parameters. + const CreateOrderProvider._({ + required CreateOrderFamily super.from, + required ({ + List> items, + Map deliveryAddress, + String paymentMethod, + bool needsInvoice, + bool needsNegotiation, + String? notes, + }) + super.argument, + }) : super( + retry: null, + name: r'createOrderProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$createOrderHash(); + + @override + String toString() { + return r'createOrderProvider' + '' + '$argument'; + } + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + final argument = + this.argument + as ({ + List> items, + Map deliveryAddress, + String paymentMethod, + bool needsInvoice, + bool needsNegotiation, + String? notes, + }); + return createOrder( + ref, + items: argument.items, + deliveryAddress: argument.deliveryAddress, + paymentMethod: argument.paymentMethod, + needsInvoice: argument.needsInvoice, + needsNegotiation: argument.needsNegotiation, + notes: argument.notes, + ); + } + + @override + bool operator ==(Object other) { + return other is CreateOrderProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$createOrderHash() => r'2d13526815e19a2bbef2f2974dad991d8ffcb594'; + +/// Create Order Provider +/// +/// Creates a new order with the given parameters. + +final class CreateOrderFamily extends $Family + with + $FunctionalFamilyOverride< + FutureOr>, + ({ + List> items, + Map deliveryAddress, + String paymentMethod, + bool needsInvoice, + bool needsNegotiation, + String? notes, + }) + > { + const CreateOrderFamily._() + : super( + retry: null, + name: r'createOrderProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Create Order Provider + /// + /// Creates a new order with the given parameters. + + CreateOrderProvider call({ + required List> items, + required Map deliveryAddress, + required String paymentMethod, + bool needsInvoice = false, + bool needsNegotiation = false, + String? notes, + }) => CreateOrderProvider._( + argument: ( + items: items, + deliveryAddress: deliveryAddress, + paymentMethod: paymentMethod, + needsInvoice: needsInvoice, + needsNegotiation: needsNegotiation, + notes: notes, + ), + from: this, + ); + + @override + String toString() => r'createOrderProvider'; +} diff --git a/lib/features/orders/presentation/providers/order_status_provider.dart b/lib/features/orders/presentation/providers/order_status_provider.dart new file mode 100644 index 0000000..8e1c9c4 --- /dev/null +++ b/lib/features/orders/presentation/providers/order_status_provider.dart @@ -0,0 +1,20 @@ +/// Order Status Provider +/// +/// Provides order status list from the API. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/features/orders/domain/entities/order_status.dart'; +import 'package:worker/features/orders/presentation/providers/order_data_providers.dart'; + +part 'order_status_provider.g.dart'; + +/// Provider for fetching order status list +/// +/// This provider automatically fetches the list when accessed. +/// Returns AsyncValue> which handles loading/error states. +@riverpod +Future> orderStatusList(Ref ref) async { + final repository = await ref.watch(orderRepositoryProvider.future); + return repository.getOrderStatusList(); +} diff --git a/lib/features/orders/presentation/providers/order_status_provider.g.dart b/lib/features/orders/presentation/providers/order_status_provider.g.dart new file mode 100644 index 0000000..d3c8665 --- /dev/null +++ b/lib/features/orders/presentation/providers/order_status_provider.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'order_status_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Provider for fetching order status list +/// +/// This provider automatically fetches the list when accessed. +/// Returns AsyncValue> which handles loading/error states. + +@ProviderFor(orderStatusList) +const orderStatusListProvider = OrderStatusListProvider._(); + +/// Provider for fetching order status list +/// +/// This provider automatically fetches the list when accessed. +/// Returns AsyncValue> which handles loading/error states. + +final class OrderStatusListProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// Provider for fetching order status list + /// + /// This provider automatically fetches the list when accessed. + /// Returns AsyncValue> which handles loading/error states. + const OrderStatusListProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'orderStatusListProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$orderStatusListHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return orderStatusList(ref); + } +} + +String _$orderStatusListHash() => r'feb0d93e57f22e0c39c34e0a655c0972d874904e'; diff --git a/lib/features/orders/presentation/providers/payment_terms_provider.dart b/lib/features/orders/presentation/providers/payment_terms_provider.dart new file mode 100644 index 0000000..5dab53e --- /dev/null +++ b/lib/features/orders/presentation/providers/payment_terms_provider.dart @@ -0,0 +1,20 @@ +/// Payment Terms Provider +/// +/// Provides payment terms list from the API. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/features/orders/domain/entities/payment_term.dart'; +import 'package:worker/features/orders/presentation/providers/order_data_providers.dart'; + +part 'payment_terms_provider.g.dart'; + +/// Provider for fetching payment terms list +/// +/// This provider automatically fetches the list when accessed. +/// Returns AsyncValue> which handles loading/error states. +@riverpod +Future> paymentTermsList(Ref ref) async { + final repository = await ref.watch(orderRepositoryProvider.future); + return repository.getPaymentTermsList(); +} diff --git a/lib/features/orders/presentation/providers/payment_terms_provider.g.dart b/lib/features/orders/presentation/providers/payment_terms_provider.g.dart new file mode 100644 index 0000000..27afe61 --- /dev/null +++ b/lib/features/orders/presentation/providers/payment_terms_provider.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'payment_terms_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Provider for fetching payment terms list +/// +/// This provider automatically fetches the list when accessed. +/// Returns AsyncValue> which handles loading/error states. + +@ProviderFor(paymentTermsList) +const paymentTermsListProvider = PaymentTermsListProvider._(); + +/// Provider for fetching payment terms list +/// +/// This provider automatically fetches the list when accessed. +/// Returns AsyncValue> which handles loading/error states. + +final class PaymentTermsListProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// Provider for fetching payment terms list + /// + /// This provider automatically fetches the list when accessed. + /// Returns AsyncValue> which handles loading/error states. + const PaymentTermsListProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'paymentTermsListProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$paymentTermsListHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return paymentTermsList(ref); + } +} + +String _$paymentTermsListHash() => r'6074016c04d947058b731c334b8f84fe85c36124'; diff --git a/lib/features/orders/presentation/widgets/order_card.dart b/lib/features/orders/presentation/widgets/order_card.dart index 0d7d8c4..9e6463e 100644 --- a/lib/features/orders/presentation/widgets/order_card.dart +++ b/lib/features/orders/presentation/widgets/order_card.dart @@ -6,7 +6,6 @@ library; import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:worker/core/database/models/enums.dart'; import 'package:worker/core/theme/colors.dart'; diff --git a/lib/features/products/presentation/pages/product_detail_page.dart b/lib/features/products/presentation/pages/product_detail_page.dart index 0370bb6..23989b8 100644 --- a/lib/features/products/presentation/pages/product_detail_page.dart +++ b/lib/features/products/presentation/pages/product_detail_page.dart @@ -66,6 +66,7 @@ class _ProductDetailPageState extends ConsumerState { // Show feedback final isFavorite = ref.read(isFavoriteProvider(widget.productId)); if (mounted) { + ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -117,6 +118,7 @@ class _ProductDetailPageState extends ConsumerState { title: const Text('Chia sẻ qua tin nhắn'), onTap: () { Navigator.pop(context); + ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Đang phát triển tính năng chat'), diff --git a/lib/features/products/presentation/pages/products_page.dart b/lib/features/products/presentation/pages/products_page.dart index 3f8aac6..ab5251d 100644 --- a/lib/features/products/presentation/pages/products_page.dart +++ b/lib/features/products/presentation/pages/products_page.dart @@ -34,7 +34,6 @@ class ProductsPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final productsAsync = ref.watch(productsProvider); - final cartItemCount = ref.watch(cartItemCountProvider); // Preload filter options for better UX when opening filter drawer ref.watch(productFilterOptionsProvider); @@ -53,16 +52,21 @@ class ProductsPage extends ConsumerWidget { foregroundColor: AppColors.grey900, centerTitle: false, actions: [ - // Cart Icon with Badge - IconButton( - icon: Badge( - label: Text('$cartItemCount'), - backgroundColor: AppColors.danger, - textColor: AppColors.white, - isLabelVisible: cartItemCount > 0, - child: const FaIcon(FontAwesomeIcons.cartShopping, color: Colors.black, size: 20), - ), - onPressed: () => context.push(RouteNames.cart), + // Cart Icon with Badge (extracted to Consumer to prevent full page rebuild) + Consumer( + builder: (context, ref, child) { + final cartItemCount = ref.watch(cartItemCountProvider); + return IconButton( + icon: Badge( + label: Text('$cartItemCount'), + backgroundColor: AppColors.danger, + textColor: AppColors.white, + isLabelVisible: cartItemCount > 0, + child: const FaIcon(FontAwesomeIcons.cartShopping, color: Colors.black, size: 20), + ), + onPressed: () => context.push(RouteNames.cart), + ); + }, ), const SizedBox(width: AppSpacing.sm), ], @@ -129,13 +133,29 @@ class ProductsPage extends ConsumerWidget { // Add to cart ref.read(cartProvider.notifier).addToCart(product); - ScaffoldMessenger.of(context).showSnackBar( + // Show SnackBar with manual dismissal + final messenger = ScaffoldMessenger.of(context) + ..clearSnackBars(); + + final controller = messenger.showSnackBar( SnackBar( content: Text('${product.name} đã thêm vào giỏ hàng'), - duration: const Duration(seconds: 2), - action: SnackBarAction(label: 'Xem', onPressed: () => context.go(RouteNames.cart)), + duration: const Duration(days: 365), // Prevent auto-dismiss + behavior: SnackBarBehavior.floating, + action: SnackBarAction( + label: 'Xem', + onPressed: () { + messenger.hideCurrentSnackBar(); + context.go(RouteNames.cart); + }, + ), ), ); + + // Manually dismiss after 2 seconds + Future.delayed(const Duration(seconds: 2), () { + controller.close(); + }); }, ); },