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

90
docs/order.sh Normal file
View File

@@ -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á
}
]
}'

View File

@@ -16,7 +16,7 @@
<i class="fas fa-check"></i> <i class="fas fa-check"></i>
</div> </div>
<h1 class="success-title">Đặt hàng thành công!</h1> <h1 class="success-title">Tạo đơn hàng thành công!</h1>
<p class="success-message"> <p class="success-message">
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ờ. 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ờ.
</p> </p>
@@ -46,7 +46,7 @@
</div> </div>
<!-- Next Steps --> <!-- Next Steps -->
<div class="card"> <!-- <div class="card">
<h3 class="card-title">Các bước tiếp theo</h3> <h3 class="card-title">Các bước tiếp theo</h3>
<div style="display: flex; align-items: flex-start; margin-bottom: 12px;"> <div style="display: flex; align-items: flex-start; margin-bottom: 12px;">
<div style="width: 24px; height: 24px; background: var(--primary-blue); color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; margin-right: 12px; flex-shrink: 0;">1</div> <div style="width: 24px; height: 24px; background: var(--primary-blue); color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; margin-right: 12px; flex-shrink: 0;">1</div>
@@ -69,7 +69,7 @@
<p class="text-small text-muted">Vận chuyển đến địa chỉ của bạn</p> <p class="text-small text-muted">Vận chuyển đến địa chỉ của bạn</p>
</div> </div>
</div> </div>
</div> </div>-->
<!-- Action Buttons --> <!-- Action Buttons -->
<a href="#" class="btn btn-primary btn-block mb-2"> <a href="#" class="btn btn-primary btn-block mb-2">

View File

@@ -122,7 +122,7 @@
<div class="info-row"> <div class="info-row">
<span class="info-label">Nội dung:</span> <span class="info-label">Nội dung:</span>
<span class="info-value">DH001234 La Nguyen Quynh</span> <span class="info-value">DH001234</span>
<button class="copy-btn" onclick="copyText('DH001234 La Nguyen Quynh')"> <button class="copy-btn" onclick="copyText('DH001234 La Nguyen Quynh')">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
</button> </button>
@@ -139,12 +139,15 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="action-buttons"> <div class="action-buttons">
<button class="btn btn-secondary" onclick="confirmPayment()"> <!--<button class="btn btn-secondary" onclick="confirmPayment()">
<i class="fas fa-check"></i> Đã thanh toán <i class="fas fa-check"></i> Đã thanh toán
</button> </button>-->
<button class="btn btn-primary" onclick="uploadProof()"> <button class="btn btn-primary" onclick="uploadProof()">
<i class="fas fa-camera"></i> Upload bill chuyển khoản <i class="fas fa-camera"></i> Upload bill chuyển khoản
</button> </button>
<a href="index.html" class="btn btn-secondary btn-block">
<i class="fas fa-home"></i> Quay về trang chủ
</a>
</div> </div>
<!-- Timer --> <!-- Timer -->

View File

@@ -211,10 +211,15 @@ class ApiConstants {
// Order Endpoints // Order Endpoints
// ============================================================================ // ============================================================================
/// Create new order /// Get order status list (requires sid and csrf_token)
/// POST /orders /// POST /api/method/building_material.building_material.api.sales_order.get_order_status_list
/// Body: { "items": [...], "deliveryAddress": {...}, "paymentMethod": "..." } /// Returns: { "message": [{ "status": "...", "label": "...", "color": "...", "index": 0 }] }
static const String createOrder = '/orders'; 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 user's orders
/// GET /orders?status={status}&page={page}&limit={limit} /// GET /orders?status={status}&page={page}&limit={limit}

View File

@@ -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_detail_page.dart';
import 'package:worker/features/news/presentation/pages/news_list_page.dart'; import 'package:worker/features/news/presentation/pages/news_list_page.dart';
import 'package:worker/features/orders/presentation/pages/order_detail_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/orders_page.dart';
import 'package:worker/features/orders/presentation/pages/payment_detail_page.dart'; import 'package:worker/features/orders/presentation/pages/payment_detail_page.dart';
import 'package:worker/features/orders/presentation/pages/payment_qr_page.dart'; import 'package:worker/features/orders/presentation/pages/payment_qr_page.dart';
@@ -327,6 +328,29 @@ final routerProvider = Provider<GoRouter>((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 // Quotes Route
GoRoute( GoRoute(
path: RouteNames.quotes, path: RouteNames.quotes,

View File

@@ -42,7 +42,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
// Controllers // Controllers
final _phoneController = TextEditingController(text: "0978113710"); final _phoneController = TextEditingController(text: "0986788766");
final _passwordController = TextEditingController(text: "123456"); final _passwordController = TextEditingController(text: "123456");
// Focus nodes // Focus nodes

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/order_summary_section.dart';
import 'package:worker/features/cart/presentation/widgets/payment_method_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/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 /// Checkout Page
/// ///
@@ -51,12 +53,15 @@ class CheckoutPage extends HookConsumerWidget {
// Invoice section // Invoice section
final needsInvoice = useState<bool>(false); final needsInvoice = useState<bool>(false);
// Payment method // Payment method (will be set to first payment term name from API)
final paymentMethod = useState<String>('full_payment'); final paymentMethod = useState<String>('');
// Price negotiation // Price negotiation
final needsNegotiation = useState<bool>(false); final needsNegotiation = useState<bool>(false);
// Watch API provider for payment terms
final paymentTermsListAsync = ref.watch(paymentTermsListProvider);
// Get CartItemData from navigation // Get CartItemData from navigation
final cartItemsData = checkoutData?['cartItems'] as List<dynamic>? ?? []; final cartItemsData = checkoutData?['cartItems'] as List<dynamic>? ?? [];
@@ -85,7 +90,7 @@ class CheckoutPage extends HookConsumerWidget {
); );
// TODO: Fetch member discount from user profile API // 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); final memberDiscount = subtotal * (memberDiscountPercent / 100);
// TODO: Fetch shipping fee from API based on address // 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) // Payment Method Section (hidden if negotiation is checked)
if (!needsNegotiation.value) 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) if (!needsNegotiation.value)
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
@@ -164,6 +240,7 @@ class CheckoutPage extends HookConsumerWidget {
// Price Negotiation Section // Price Negotiation Section
PriceNegotiationSection(needsNegotiation: needsNegotiation), PriceNegotiationSection(needsNegotiation: needsNegotiation),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
// Terms and Conditions // Terms and Conditions
@@ -200,6 +277,8 @@ class CheckoutPage extends HookConsumerWidget {
selectedAddress: selectedAddress.value, selectedAddress: selectedAddress.value,
paymentMethod: paymentMethod.value, paymentMethod: paymentMethod.value,
total: total, total: total,
cartItems: checkoutItems,
notes: notesController.text.trim().isEmpty ? null : notesController.text.trim(),
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),

View File

@@ -8,11 +8,7 @@ import 'package:worker/features/products/domain/entities/product.dart';
/// Cart Item Data /// Cart Item Data
/// ///
/// Represents a product in the cart with quantity. /// Represents a product in the cart with quantity.
class CartItemData { class CartItemData { // Number of tiles/boxes needed
final Product product;
final double quantity;
final double quantityConverted; // Rounded-up quantity for actual billing
final int boxes; // Number of tiles/boxes needed
const CartItemData({ const CartItemData({
required this.product, required this.product,
@@ -20,6 +16,10 @@ class CartItemData {
required this.quantityConverted, required this.quantityConverted,
required this.boxes, 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) /// Calculate line total using CONVERTED quantity (important for accurate billing)
double get lineTotal => product.basePrice * quantityConverted; double get lineTotal => product.basePrice * quantityConverted;
@@ -43,19 +43,6 @@ class CartItemData {
/// ///
/// Represents the complete state of the shopping cart. /// Represents the complete state of the shopping cart.
class CartState { 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({ const CartState({
required this.items, required this.items,
@@ -88,6 +75,19 @@ class CartState {
total: 0.0, 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 isEmpty => items.isEmpty;
bool get isNotEmpty => items.isNotEmpty; bool get isNotEmpty => items.isNotEmpty;

View File

@@ -5,16 +5,18 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.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/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'; import 'package:worker/features/account/domain/entities/address.dart';
import 'package:worker/features/orders/presentation/providers/order_repository_provider.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 HookConsumerWidget {
const CheckoutSubmitButton({ const CheckoutSubmitButton({
super.key, super.key,
required this.formKey, required this.formKey,
@@ -23,6 +25,8 @@ class CheckoutSubmitButton extends StatelessWidget {
required this.selectedAddress, required this.selectedAddress,
required this.paymentMethod, required this.paymentMethod,
required this.total, required this.total,
required this.cartItems,
this.notes,
}); });
final GlobalKey<FormState> formKey; final GlobalKey<FormState> formKey;
@@ -31,9 +35,11 @@ class CheckoutSubmitButton extends StatelessWidget {
final Address? selectedAddress; final Address? selectedAddress;
final String paymentMethod; final String paymentMethod;
final double total; final double total;
final List<Map<String, dynamic>> cartItems;
final String? notes;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
@@ -52,7 +58,7 @@ class CheckoutSubmitButton extends StatelessWidget {
} }
if (formKey.currentState?.validate() ?? false) { if (formKey.currentState?.validate() ?? false) {
_handlePlaceOrder(context); _handlePlaceOrder(context, ref);
} }
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -78,48 +84,98 @@ class CheckoutSubmitButton extends StatelessWidget {
} }
/// Handle place order /// Handle place order
void _handlePlaceOrder(BuildContext context) { Future<void> _handlePlaceOrder(BuildContext context, WidgetRef ref) async {
// TODO: Implement actual order placement with backend // Show loading indicator
showDialog<void>(
if (needsNegotiation) { context: context,
// Show negotiation request sent message barrierDismissible: false,
ScaffoldMessenger.of(context).showSnackBar( builder: (context) => const Center(
const SnackBar( child: CircularProgressIndicator(),
content: Text('Yêu cầu đàm phán giá đã được gửi!'),
backgroundColor: AppColors.success,
duration: Duration(seconds: 2),
), ),
); );
// Navigate back after a short delay try {
Future.delayed(const Duration(milliseconds: 500), () { // 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,
};
// 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) { if (context.mounted) {
context.pop(); Navigator.of(context).pop();
} }
});
} else { // Extract order number from response
// Generate order ID (mock - replace with actual from backend) final orderNumber = result['orderNumber'] as String? ??
final orderId = result['orderId'] as String? ??
'DH${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}'; 'DH${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}';
// Show order success message if (needsNegotiation) {
ScaffoldMessenger.of(context).showSnackBar( // Navigate to order success page with negotiation flag
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) { if (context.mounted) {
context.pushNamed( context.pushReplacementNamed(
RouteNames.paymentQr, RouteNames.orderSuccess,
queryParameters: {'orderId': orderId, 'amount': total.toString()}, 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 Section Widget
/// ///
/// Payment method selection with two options: /// Payment method selection with dynamic options from API.
/// 1. Full payment via bank transfer
/// 2. Partial payment (>=20%, 30 day terms)
library; library;
import 'package:flutter/material.dart'; 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:font_awesome_flutter/font_awesome_flutter.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/orders/domain/entities/payment_term.dart';
/// Payment Method Section /// Payment Method Section
/// ///
/// Two payment options matching checkout.html design. /// Displays payment options from API matching checkout.html design.
class PaymentMethodSection extends HookWidget { class PaymentMethodSection extends HookWidget {
final ValueNotifier<String> paymentMethod; 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 @override
Widget build(BuildContext context) { 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( 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),
@@ -50,15 +82,29 @@ class PaymentMethodSection extends HookWidget {
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
// Full Payment Option // 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( InkWell(
onTap: () => paymentMethod.value = 'full_payment', onTap: () => paymentMethod.value = term.name,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Row( child: Row(
children: [ children: [
Radio<String>( Radio<String>(
value: 'full_payment', value: term.name,
groupValue: paymentMethod.value, groupValue: paymentMethod.value,
onChanged: (value) { onChanged: (value) {
paymentMethod.value = value!; paymentMethod.value = value!;
@@ -66,27 +112,27 @@ class PaymentMethodSection extends HookWidget {
activeColor: AppColors.primaryBlue, activeColor: AppColors.primaryBlue,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
const Icon( Icon(
FontAwesomeIcons.buildingColumns, icon,
color: AppColors.grey500, color: AppColors.grey500,
size: 24, size: 24,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
const Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Thanh toán hoàn toàn', term.name,
style: TextStyle( style: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Thanh toán qua tài khoản ngân hàng', term.customDescription,
style: TextStyle( style: const TextStyle(
fontSize: 13, fontSize: 13,
color: AppColors.grey500, color: AppColors.grey500,
), ),
@@ -98,57 +144,9 @@ class PaymentMethodSection extends HookWidget {
), ),
), ),
), ),
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,
),
),
], ],
), );
), }),
],
),
),
),
], ],
), ),
); );

View File

@@ -23,7 +23,7 @@ class InvoicesLocalDataSource {
throw Exception('Invalid JSON format: expected List'); throw Exception('Invalid JSON format: expected List');
} }
final invoices = (decoded as List<dynamic>) final invoices = decoded
.map((json) => InvoiceModel.fromJson(json as Map<String, dynamic>)) .map((json) => InvoiceModel.fromJson(json as Map<String, dynamic>))
.toList(); .toList();

View File

@@ -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<List<OrderStatusModel>> getOrderStatusList() async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.getOrderStatusList}',
data: <String, dynamic>{},
);
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<dynamic> statusList = message as List<dynamic>;
return statusList
.map((json) => OrderStatusModel.fromJson(json as Map<String, dynamic>))
.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<List<PaymentTermModel>> getPaymentTermsList() async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${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<dynamic> termsList = message as List<dynamic>;
return termsList
.map((json) => PaymentTermModel.fromJson(json as Map<String, dynamic>))
.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<Map<String, dynamic>> createOrder({
required List<Map<String, dynamic>> items,
required Map<String, dynamic> 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<Map<String, dynamic>>(
'${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<String, dynamic>?;
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');
}
}
}

View File

@@ -24,7 +24,7 @@ class OrdersLocalDataSource {
throw Exception('Invalid JSON format: expected List'); throw Exception('Invalid JSON format: expected List');
} }
final orders = (decoded as List<dynamic>) final orders = decoded
.map((json) => OrderModel.fromJson(json as Map<String, dynamic>)) .map((json) => OrderModel.fromJson(json as Map<String, dynamic>))
.toList(); .toList();

View File

@@ -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<String, dynamic> 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<String, dynamic> 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<Object?> get props => [status, label, color, index];
}

View File

@@ -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<String, dynamic> json) {
return PaymentTermModel(
name: json['name'] as String,
customDescription: json['custom_description'] as String?,
);
}
/// Convert to JSON
Map<String, dynamic> 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<Object?> get props => [name, customDescription];
}

View File

@@ -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<List<OrderStatus>> 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<List<PaymentTerm>> 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<Map<String, dynamic>> createOrder({
required List<Map<String, dynamic>> items,
required Map<String, dynamic> 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');
}
}
}

View File

@@ -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<Object?> get props => [status, label, color, index];
}

View File

@@ -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<Object?> get props => [name, customDescription];
}

View File

@@ -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<List<OrderStatus>> getOrderStatusList();
/// Get list of available payment terms
Future<List<PaymentTerm>> getPaymentTermsList();
/// Create new order
Future<Map<String, dynamic>> createOrder({
required List<Map<String, dynamic>> items,
required Map<String, dynamic> deliveryAddress,
required String paymentMethod,
bool needsInvoice = false,
bool needsNegotiation = false,
String? notes,
});
}

View File

@@ -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]}.')}';
}
}

View File

@@ -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> orderRemoteDataSource(Ref ref) async {
final dioClient = await ref.watch(dioClientProvider.future);
return OrderRemoteDataSource(dioClient);
}
/// Provider for Order Repository
@riverpod
Future<OrderRepository> orderRepository(Ref ref) async {
final remoteDataSource = await ref.watch(orderRemoteDataSourceProvider.future);
return OrderRepositoryImpl(remoteDataSource);
}

View File

@@ -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>,
OrderRemoteDataSource,
FutureOr<OrderRemoteDataSource>
>
with
$FutureModifier<OrderRemoteDataSource>,
$FutureProvider<OrderRemoteDataSource> {
/// 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<OrderRemoteDataSource> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<OrderRemoteDataSource> 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>,
OrderRepository,
FutureOr<OrderRepository>
>
with $FutureModifier<OrderRepository>, $FutureProvider<OrderRepository> {
/// 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<OrderRepository> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<OrderRepository> create(Ref ref) {
return orderRepository(ref);
}
}
String _$orderRepositoryHash() => r'985408a6667ab31427524f9b1981287c28f4f221';

View File

@@ -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> 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<Map<String, dynamic>> createOrder(
Ref ref, {
required List<Map<String, dynamic>> items,
required Map<String, dynamic> 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,
);
}

View File

@@ -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>,
OrderRepository,
FutureOr<OrderRepository>
>
with $FutureModifier<OrderRepository>, $FutureProvider<OrderRepository> {
/// 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<OrderRepository> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<OrderRepository> 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<String, dynamic>>,
Map<String, dynamic>,
FutureOr<Map<String, dynamic>>
>
with
$FutureModifier<Map<String, dynamic>>,
$FutureProvider<Map<String, dynamic>> {
/// Create Order Provider
///
/// Creates a new order with the given parameters.
const CreateOrderProvider._({
required CreateOrderFamily super.from,
required ({
List<Map<String, dynamic>> items,
Map<String, dynamic> 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<Map<String, dynamic>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<Map<String, dynamic>> create(Ref ref) {
final argument =
this.argument
as ({
List<Map<String, dynamic>> items,
Map<String, dynamic> 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<Map<String, dynamic>>,
({
List<Map<String, dynamic>> items,
Map<String, dynamic> 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<Map<String, dynamic>> items,
required Map<String, dynamic> 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';
}

View File

@@ -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<List<OrderStatus>> which handles loading/error states.
@riverpod
Future<List<OrderStatus>> orderStatusList(Ref ref) async {
final repository = await ref.watch(orderRepositoryProvider.future);
return repository.getOrderStatusList();
}

View File

@@ -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<List<OrderStatus>> 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<List<OrderStatus>> which handles loading/error states.
final class OrderStatusListProvider
extends
$FunctionalProvider<
AsyncValue<List<OrderStatus>>,
List<OrderStatus>,
FutureOr<List<OrderStatus>>
>
with
$FutureModifier<List<OrderStatus>>,
$FutureProvider<List<OrderStatus>> {
/// Provider for fetching order status list
///
/// This provider automatically fetches the list when accessed.
/// Returns AsyncValue<List<OrderStatus>> 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<List<OrderStatus>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<OrderStatus>> create(Ref ref) {
return orderStatusList(ref);
}
}
String _$orderStatusListHash() => r'feb0d93e57f22e0c39c34e0a655c0972d874904e';

View File

@@ -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<List<PaymentTerm>> which handles loading/error states.
@riverpod
Future<List<PaymentTerm>> paymentTermsList(Ref ref) async {
final repository = await ref.watch(orderRepositoryProvider.future);
return repository.getPaymentTermsList();
}

View File

@@ -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<List<PaymentTerm>> 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<List<PaymentTerm>> which handles loading/error states.
final class PaymentTermsListProvider
extends
$FunctionalProvider<
AsyncValue<List<PaymentTerm>>,
List<PaymentTerm>,
FutureOr<List<PaymentTerm>>
>
with
$FutureModifier<List<PaymentTerm>>,
$FutureProvider<List<PaymentTerm>> {
/// Provider for fetching payment terms list
///
/// This provider automatically fetches the list when accessed.
/// Returns AsyncValue<List<PaymentTerm>> 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<List<PaymentTerm>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<PaymentTerm>> create(Ref ref) {
return paymentTermsList(ref);
}
}
String _$paymentTermsListHash() => r'6074016c04d947058b731c334b8f84fe85c36124';

View File

@@ -6,7 +6,6 @@ library;
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:worker/core/database/models/enums.dart'; import 'package:worker/core/database/models/enums.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';

View File

@@ -66,6 +66,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
// Show feedback // Show feedback
final isFavorite = ref.read(isFavoriteProvider(widget.productId)); final isFavorite = ref.read(isFavoriteProvider(widget.productId));
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
@@ -117,6 +118,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
title: const Text('Chia sẻ qua tin nhắn'), title: const Text('Chia sẻ qua tin nhắn'),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Đang phát triển tính năng chat'), content: Text('Đang phát triển tính năng chat'),

View File

@@ -34,7 +34,6 @@ class ProductsPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final productsAsync = ref.watch(productsProvider); final productsAsync = ref.watch(productsProvider);
final cartItemCount = ref.watch(cartItemCountProvider);
// Preload filter options for better UX when opening filter drawer // Preload filter options for better UX when opening filter drawer
ref.watch(productFilterOptionsProvider); ref.watch(productFilterOptionsProvider);
@@ -53,8 +52,11 @@ class ProductsPage extends ConsumerWidget {
foregroundColor: AppColors.grey900, foregroundColor: AppColors.grey900,
centerTitle: false, centerTitle: false,
actions: [ actions: [
// Cart Icon with Badge // Cart Icon with Badge (extracted to Consumer to prevent full page rebuild)
IconButton( Consumer(
builder: (context, ref, child) {
final cartItemCount = ref.watch(cartItemCountProvider);
return IconButton(
icon: Badge( icon: Badge(
label: Text('$cartItemCount'), label: Text('$cartItemCount'),
backgroundColor: AppColors.danger, backgroundColor: AppColors.danger,
@@ -63,6 +65,8 @@ class ProductsPage extends ConsumerWidget {
child: const FaIcon(FontAwesomeIcons.cartShopping, color: Colors.black, size: 20), child: const FaIcon(FontAwesomeIcons.cartShopping, color: Colors.black, size: 20),
), ),
onPressed: () => context.push(RouteNames.cart), onPressed: () => context.push(RouteNames.cart),
);
},
), ),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
], ],
@@ -129,13 +133,29 @@ class ProductsPage extends ConsumerWidget {
// Add to cart // Add to cart
ref.read(cartProvider.notifier).addToCart(product); 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( SnackBar(
content: Text('${product.name} đã thêm vào giỏ hàng'), content: Text('${product.name} đã thêm vào giỏ hàng'),
duration: const Duration(seconds: 2), duration: const Duration(days: 365), // Prevent auto-dismiss
action: SnackBarAction(label: 'Xem', onPressed: () => context.go(RouteNames.cart)), 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();
});
}, },
); );
}, },