Compare commits

..

3 Commits

Author SHA1 Message Date
Phuoc Nguyen
06b0834822 update gen qr 2025-11-21 17:31:49 +07:00
Phuoc Nguyen
4913a4e04b create order 2025-11-21 16:50:43 +07:00
Phuoc Nguyen
f2f95849d4 bump 2025-11-20 17:07:07 +07:00
34 changed files with 1883 additions and 226 deletions

118
docs/order.sh Normal file
View File

@@ -0,0 +1,118 @@
#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á
}
]
}'
#create order response
Response: {message: {success: true, message: Sales Order created successfully, data: {name: SAL-ORD-2025-00078, status_color: Warning, status: Chờ phê duyệt, grand_total: 589824.0}}}
#gen qrcode
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.v1.qrcode.generate' \
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d' \
--header 'Content-Type: application/json' \
--data '{
"order_id" : "SAL-ORD-2025-00048"
}'
#gen qrcode response
{
"message": {
"qr_code": "00020101021238540010A00000072701240006970422011008490428160208QRIBFTTA53037045802VN62220818SAL-ORD-2025-00048630430F4",
"amount": null,
"transaction_id": "SAL-ORD-2025-00048",
"bank_info": {
"bank_name": "MB Bank",
"account_no": "0849042816",
"account_name": "NGUYEN MINH CHAU"
}
}
}

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

@@ -98,9 +98,9 @@ PODS:
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- PromisesObjC (2.4.0) - PromisesObjC (2.4.0)
- SDWebImage (5.21.2): - SDWebImage (5.21.3):
- SDWebImage/Core (= 5.21.2) - SDWebImage/Core (= 5.21.3)
- SDWebImage/Core (5.21.2) - SDWebImage/Core (5.21.3)
- share_plus (0.0.1): - share_plus (0.0.1):
- Flutter - Flutter
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
@@ -195,7 +195,7 @@ SPEC CHECKSUMS:
nanopb: 438bc412db1928dac798aa6fd75726007be04262 nanopb: 438bc412db1928dac798aa6fd75726007be04262
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d

View File

@@ -211,10 +211,21 @@ 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';
/// Generate QR code for payment (requires sid and csrf_token)
/// POST /api/method/building_material.building_material.api.v1.qrcode.generate
/// Body: { "order_id": "SAL-ORD-2025-00048" }
/// Returns: { "message": { "qr_code": "...", "amount": null, "transaction_id": "...", "bank_info": {...} } }
static const String generateQrCode = '/building_material.building_material.api.v1.qrcode.generate';
/// 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';
@@ -322,7 +323,33 @@ final routerProvider = Provider<GoRouter>((ref) {
final amount = double.tryParse(amountStr) ?? 0.0; final amount = double.tryParse(amountStr) ?? 0.0;
return MaterialPage( return MaterialPage(
key: state.pageKey, key: state.pageKey,
child: PaymentQrPage(orderId: orderId, amount: amount), child: PaymentQrPage(
orderId: orderId,
amount: amount,
),
);
},
),
// 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,
),
); );
}, },
), ),

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,99 @@ 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
if (context.mounted) { final deliveryAddressData = {
context.pop(); 'name': selectedAddress!.name,
} 'phone': selectedAddress!.phone,
}); 'street': selectedAddress!.addressLine1,
} else { 'ward': selectedAddress!.wardName ?? selectedAddress!.wardCode,
// Generate order ID (mock - replace with actual from backend) 'city': selectedAddress!.cityName ?? selectedAddress!.cityCode,
final orderId = };
// 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);
// Extract order number from response
final orderNumber = result['orderNumber'] as String? ??
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( Navigator.of(context).pop();
RouteNames.paymentQr, context.pushReplacementNamed(
queryParameters: {'orderId': orderId, 'amount': total.toString()}, RouteNames.orderSuccess,
queryParameters: {
'orderNumber': orderNumber,
'total': total.toString(),
'isNegotiation': 'true',
},
);
}
} else {
// Close loading dialog
if (context.mounted) {
Navigator.of(context).pop();
}
// Navigate to payment QR page (it will fetch QR code data itself)
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,197 @@
/// 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
// Response format: { message: { success: true, message: "...", data: { name: "SAL-ORD-2025-00078", ... } } }
final message = data['message'] as Map<String, dynamic>?;
if (message == null) {
throw Exception('No message field in createOrder response');
}
final orderData = message['data'] as Map<String, dynamic>?;
if (orderData == null) {
throw Exception('No data field in createOrder response');
}
final orderId = orderData['name'] as String?;
if (orderId == null || orderId.isEmpty) {
throw Exception('No order ID (name) in createOrder response');
}
// Return standardized response
return {
'orderId': orderId,
'orderNumber': orderId,
'fullResponse': orderData,
};
} catch (e) {
throw Exception('Failed to create order: $e');
}
}
/// Generate QR code for payment
///
/// Calls: POST /api/method/building_material.building_material.api.v1.qrcode.generate
/// Body: { "order_id": "SAL-ORD-2025-00048" }
/// Returns: { "qr_code": "...", "amount": null, "transaction_id": "...", "bank_info": {...} }
Future<Map<String, dynamic>> generateQrCode(String orderId) async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.generateQrCode}',
data: {'order_id': orderId},
);
final data = response.data;
if (data == null) {
throw Exception('No data received from generateQrCode API');
}
// Extract QR code info from Frappe response
final message = data['message'] as Map<String, dynamic>?;
if (message == null) {
throw Exception('No message field in generateQrCode response');
}
return message;
} catch (e) {
throw Exception('Failed to generate QR code: $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,68 @@
/// 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');
}
}
@override
Future<Map<String, dynamic>> generateQrCode(String orderId) async {
try {
return await _remoteDataSource.generateQrCode(orderId);
} catch (e) {
throw Exception('Failed to generate QR code: $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,29 @@
/// 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,
});
/// Generate QR code for payment
Future<Map<String, dynamic>> generateQrCode(String orderId);
}

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

@@ -17,9 +17,11 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:qr_flutter/qr_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/presentation/providers/order_repository_provider.dart';
/// Payment QR Page /// Payment QR Page
/// ///
@@ -28,14 +30,43 @@ class PaymentQrPage extends HookConsumerWidget {
final String orderId; final String orderId;
final double amount; final double amount;
const PaymentQrPage({super.key, required this.orderId, required this.amount}); const PaymentQrPage({
super.key,
required this.orderId,
required this.amount,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// QR code data state
final qrCodeData = useState<Map<String, dynamic>?>(null);
final isLoadingQr = useState<bool>(true);
final qrError = useState<String?>(null);
// Countdown timer (15 minutes = 900 seconds) // Countdown timer (15 minutes = 900 seconds)
final remainingSeconds = useState<int>(900); final remainingSeconds = useState<int>(900);
final timer = useRef<Timer?>(null); final timer = useRef<Timer?>(null);
// Fetch QR code data
useEffect(() {
Future<void> fetchQrCode() async {
try {
isLoadingQr.value = true;
qrError.value = null;
final repository = await ref.read(orderRepositoryProvider.future);
final data = await repository.generateQrCode(orderId);
qrCodeData.value = data;
} catch (e) {
qrError.value = e.toString();
} finally {
isLoadingQr.value = false;
}
}
fetchQrCode();
return null;
}, [orderId]);
// Start countdown timer // Start countdown timer
useEffect(() { useEffect(() {
timer.value = Timer.periodic(const Duration(seconds: 1), (t) { timer.value = Timer.periodic(const Duration(seconds: 1), (t) {
@@ -94,12 +125,24 @@ class PaymentQrPage extends HookConsumerWidget {
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
// QR Code Card // QR Code Card
_buildQrCodeCard(amount, orderId), _buildQrCodeCard(
amount,
orderId,
qrCodeData.value?['qr_code'] as String?,
isLoadingQr.value,
),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
// Bank Transfer Info Card // Bank Transfer Info Card
_buildBankInfoCard(context, orderId), _buildBankInfoCard(
context,
orderId,
qrCodeData.value?['bank_info']?['bank_name'] as String?,
qrCodeData.value?['bank_info']?['account_no'] as String?,
qrCodeData.value?['bank_info']?['account_name'] as String?,
isLoadingQr.value,
),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
@@ -180,14 +223,12 @@ class PaymentQrPage extends HookConsumerWidget {
} }
/// Build QR code card /// Build QR code card
Widget _buildQrCodeCard(double amount, String orderId) { Widget _buildQrCodeCard(
// Generate QR code data URL double amount,
final qrData = Uri.encodeComponent( String orderId,
'https://eurotile.com/payment/$orderId?amount=$amount', String? qrCodeData,
); bool isLoading,
final qrUrl = ) {
'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=$qrData';
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),
@@ -222,11 +263,19 @@ class PaymentQrPage extends HookConsumerWidget {
borderRadius: BorderRadius.circular(AppRadius.card), borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: const Color(0xFFE2E8F0)), border: Border.all(color: const Color(0xFFE2E8F0)),
), ),
child: Image.network( child: isLoading
qrUrl, ? const Center(
fit: BoxFit.contain, child: CircularProgressIndicator(),
errorBuilder: (context, error, stackTrace) { )
return const Column( : qrCodeData != null && qrCodeData.isNotEmpty
? QrImageView(
data: qrCodeData,
version: QrVersions.auto,
size: 200.0,
backgroundColor: Colors.white,
errorCorrectionLevel: QrErrorCorrectLevel.M,
)
: const Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
FaIcon(FontAwesomeIcons.qrcode, size: 80, color: AppColors.grey500), FaIcon(FontAwesomeIcons.qrcode, size: 80, color: AppColors.grey500),
@@ -236,8 +285,6 @@ class PaymentQrPage extends HookConsumerWidget {
style: TextStyle(fontSize: 12, color: AppColors.grey500), style: TextStyle(fontSize: 12, color: AppColors.grey500),
), ),
], ],
);
},
), ),
), ),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
@@ -252,7 +299,14 @@ class PaymentQrPage extends HookConsumerWidget {
} }
/// Build bank transfer info card /// Build bank transfer info card
Widget _buildBankInfoCard(BuildContext context, String orderId) { Widget _buildBankInfoCard(
BuildContext context,
String orderId,
String? bankName,
String? accountNo,
String? accountName,
bool isLoading,
) {
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),
@@ -281,7 +335,11 @@ class PaymentQrPage extends HookConsumerWidget {
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
// Bank Name // Bank Name
_buildInfoRow(context: context, label: 'Ngân hàng:', value: 'BIDV'), _buildInfoRow(
context: context,
label: 'Ngân hàng:',
value: bankName ?? 'BIDV',
),
const Divider(height: 24), const Divider(height: 24),
@@ -289,7 +347,7 @@ class PaymentQrPage extends HookConsumerWidget {
_buildInfoRow( _buildInfoRow(
context: context, context: context,
label: 'Số tài khoản:', label: 'Số tài khoản:',
value: '19036810704016', value: accountNo ?? '19036810704016',
), ),
const Divider(height: 24), const Divider(height: 24),
@@ -298,7 +356,7 @@ class PaymentQrPage extends HookConsumerWidget {
_buildInfoRow( _buildInfoRow(
context: context, context: context,
label: 'Chủ tài khoản:', label: 'Chủ tài khoản:',
value: 'CÔNG TY EUROTILE', value: accountName ?? 'CÔNG TY EUROTILE',
), ),
const Divider(height: 24), const Divider(height: 24),
@@ -307,7 +365,7 @@ class PaymentQrPage extends HookConsumerWidget {
_buildInfoRow( _buildInfoRow(
context: context, context: context,
label: 'Nội dung:', label: 'Nội dung:',
value: '$orderId La Nguyen Quynh', value: orderId,
), ),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),

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();
});
}, },
); );
}, },

View File

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