Compare commits

...

3 Commits

Author SHA1 Message Date
Phuoc Nguyen
1851d60038 update order detail 2025-11-24 17:00:11 +07:00
Phuoc Nguyen
75d6507719 list orders 2025-11-24 16:25:54 +07:00
Phuoc Nguyen
354df3ad01 create order -> upload bill 2025-11-24 14:53:48 +07:00
31 changed files with 2785 additions and 1506 deletions

View File

@@ -116,3 +116,155 @@ curl --location 'https://land.dbiz.com//api/method/building_material.building_ma
} }
} }
} }
#upload bill
curl --location 'https://land.dbiz.com//api/method/upload_file' \
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d' \
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
--form 'file=@"/C:/Users/tiennld/Downloads/logo_crm.png"' \
--form 'is_private="1"' \
--form 'folder="Home/Attachments"' \
--form 'doctype="Sales Order"' \
--form 'docname="SAL-ORD-2025-00058-1"' \
--form 'optimize="true"'
#get list order
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.sales_order.get_list' \
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; full_name=Ha%20Duy%20Lam; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; system_user=yes; user_id=lamhd%40gmail.com; user_image=/files/avatar_0986788766_1763627962.jpg' \
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
--header 'Content-Type: application/json' \
--data '{
"limit_start" : 0,
"limit_page_length" : 0
}'
#response list order
{
"message": [
{
"name": "SAL-ORD-2025-00107",
"transaction_date": "2025-11-24",
"delivery_date": "2025-11-24",
"address": "123 add dad",
"grand_total": 3355443.2,
"status": "Chờ phê duyệt",
"status_color": "Warning"
},
{
"name": "SAL-ORD-2025-00106",
"transaction_date": "2025-11-24",
"delivery_date": "2025-11-24",
"address": "123 add dad",
"grand_total": 3355443.2,
"status": "Chờ phê duyệt",
"status_color": "Warning"
},
...
]
}
#order detail
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.sales_order.get_detail' \
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d' \
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
--header 'Content-Type: application/json' \
--data '{
"name" : "SAL-ORD-2025-00058-1"
}'
#response order detail
{
"message": {
"order": {
"name": "SAL-ORD-2025-00107",
"customer": "test - 1",
"transaction_date": "2025-11-24",
"delivery_date": "2025-11-24",
"status": "Chờ phê duyệt",
"status_color": "Warning",
"total_qty": 2.56,
"total": 3355443.2,
"grand_total": 3355443.2,
"total_remaining": 0,
"description": "Order from mobile app",
"contract_request": false,
"ignore_pricing_rule": false,
"rejection_reason": null,
"is_allow_cancel": true
},
"billing_address": {
"name": "phuoc-Billing-3",
"address_title": "phuoc",
"address_line1": "123 add dad",
"phone": "0123123123",
"email": "123@gmail.com",
"fax": null,
"tax_code": "064521840",
"city_code": "19",
"ward_code": "01936",
"city_name": "Tỉnh Thái Nguyên",
"ward_name": "Xã Nà Phặc",
"is_allow_edit": true
},
"shipping_address": {
"name": "phuoc-Billing-3",
"address_title": "phuoc",
"address_line1": "123 add dad",
"phone": "0123123123",
"email": "123@gmail.com",
"fax": null,
"tax_code": "064521840",
"city_code": "19",
"ward_code": "01936",
"city_name": "Tỉnh Thái Nguyên",
"ward_name": "Xã Nà Phặc",
"is_allow_edit": true
},
"items": [
{
"name": "9crv0j6d4t",
"item_code": "HOA E01",
"item_name": "Hội An HOA E01",
"description": "Hội An HOA E01",
"qty_entered": 0.0,
"qty_of_sm": 2.56,
"qty_of_nos": 4.0,
"conversion_factor": 1.5625,
"price": 1310720.0,
"total_amount": 3355443.2,
"delivery_date": "2025-11-24",
"thumbnail": "https://land.dbiz.com/files/HOA-E01-f1.jpg"
}
],
"payment_terms": {
"name": "Thanh toán hoàn toàn",
"description": "Thanh toán ngay được chiết khấu 2%"
},
"timeline": [
{
"label": "Đã tạo đơn",
"value": "2025-11-24 14:46:07",
"status": "Success"
},
{
"label": "Chờ phê duyệt",
"value": null,
"status": "Warning"
},
{
"label": "Đơn đang xử lý",
"value": "Prepare goods and transport",
"status": "Secondary"
},
{
"label": "Hoàn thành",
"value": null,
"status": "Secondary"
}
],
"payments": [],
"invoices": []
}
}

View File

@@ -0,0 +1,81 @@
# Order Model API Integration Update
## Summary
Updated OrderModel and orders_provider to match the simplified API response structure from the ERPNext/Frappe backend.
## API Response Structure
```json
{
"message": [
{
"name": "SAL-ORD-2025-00107",
"transaction_date": "2025-11-24",
"delivery_date": "2025-11-24",
"address": "123 add dad",
"grand_total": 3355443.2,
"status": "Chờ phê duyệt",
"status_color": "Warning"
}
]
}
```
## Changes Made
### 1. OrderModel (`lib/features/orders/data/models/order_model.dart`)
**New Fields Added:**
- `statusColor` (HiveField 18): Stores API status color (Warning, Success, Danger, etc.)
- `transactionDate` (HiveField 19): Transaction date from API
- `addressString` (HiveField 20): Simple string address from API
**Updated Methods:**
- `fromJson()`: Made fields more nullable, added new field mappings
- `toJson()`: Added new fields to output
- Constructor: Added new optional parameters
### 2. Orders Provider (`lib/features/orders/presentation/providers/orders_provider.dart`)
**API Field Mapping:**
```dart
{
'order_id': json['name'],
'order_number': json['name'],
'status': _mapStatusFromApi(json['status']),
'total_amount': json['grand_total'],
'final_amount': json['grand_total'],
'expected_delivery_date': json['delivery_date'],
'transaction_date': json['transaction_date'],
'address_string': json['address'],
'status_color': json['status_color'],
'created_at': json['transaction_date'],
}
```
**Status Mapping:**
- "Chờ phê duyệt" / "Pending approval" → `pending`
- "Đang xử lý" / "Processing" → `processing`
- "Đang giao" / "Shipped" → `shipped`
- "Hoàn thành" / "Completed" → `completed`
- "Đã hủy" / "Cancelled" / "Rejected" → `cancelled`
### 3. Order Card Widget (`lib/features/orders/presentation/widgets/order_card.dart`)
**Display Updates:**
- Uses `transactionDate` if available, falls back to `createdAt`
- Uses `addressString` directly from API instead of parsing JSON
## Benefits
1. **Simpler mapping**: Direct field mapping without complex transformations
2. **API consistency**: Matches actual backend response structure
3. **Better performance**: No need to parse JSON addresses for list view
4. **Status colors**: API-provided colors ensure UI consistency with backend
## API Endpoint
```
POST /api/method/building_material.building_material.api.sales_order.get_list
Body: { "limit_start": 0, "limit_page_length": 0 }
```
## Testing Notes
- Ensure API returns all expected fields
- Verify Vietnamese status strings are correctly mapped
- Check that dates are in ISO format (YYYY-MM-DD)
- Confirm status_color values match StatusColor enum (Warning, Success, Danger, Info, Secondary)

View File

@@ -12,9 +12,11 @@
<div class="page-wrapper"> <div class="page-wrapper">
<!-- Header --> <!-- Header -->
<div class="header"> <div class="header">
<a href="checkout.html" class="back-button"> <!--<a href="checkout.html" class="back-button">
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
</a> </a>-->
<div style="width: 32px;"></div>
<h1 class="header-title">Thanh toán</h1> <h1 class="header-title">Thanh toán</h1>
<button class="back-button" onclick="openInfoModal()"> <button class="back-button" onclick="openInfoModal()">
<i class="fas fa-info-circle"></i> <i class="fas fa-info-circle"></i>

View File

@@ -227,7 +227,25 @@ class ApiConstants {
/// Returns: { "message": { "qr_code": "...", "amount": null, "transaction_id": "...", "bank_info": {...} } } /// Returns: { "message": { "qr_code": "...", "amount": null, "transaction_id": "...", "bank_info": {...} } }
static const String generateQrCode = '/building_material.building_material.api.v1.qrcode.generate'; static const String generateQrCode = '/building_material.building_material.api.v1.qrcode.generate';
/// Get user's orders /// Upload file (bill/invoice/attachment) (requires sid and csrf_token)
/// POST /api/method/upload_file
/// Form-data: { "file": File, "is_private": "1", "folder": "Home/Attachments", "doctype": "Sales Order", "docname": "SAL-ORD-2025-00058-1", "optimize": "true" }
/// Returns: { "message": { "file_url": "...", "file_name": "...", ... } }
static const String uploadFile = '/upload_file';
/// Get list of orders (requires sid and csrf_token)
/// POST /api/method/building_material.building_material.api.sales_order.get_list
/// Body: { "limit_start": 0, "limit_page_length": 0 }
/// Returns: { "message": [...] }
static const String getOrdersList = '/building_material.building_material.api.sales_order.get_list';
/// Get order details (requires sid and csrf_token)
/// POST /api/method/building_material.building_material.api.sales_order.get_detail
/// Body: { "name": "SAL-ORD-2025-00058-1" }
/// Returns: { "message": {...} }
static const String getOrderDetail = '/building_material.building_material.api.sales_order.get_detail';
/// Get user's orders (legacy endpoint - may be deprecated)
/// GET /orders?status={status}&page={page}&limit={limit} /// GET /orders?status={status}&page={page}&limit={limit}
static const String getOrders = '/orders'; static const String getOrders = '/orders';

View File

@@ -61,6 +61,9 @@ class HiveBoxNames {
static const String cityBox = 'city_box'; static const String cityBox = 'city_box';
static const String wardBox = 'ward_box'; static const String wardBox = 'ward_box';
/// Order status list cache
static const String orderStatusBox = 'order_status_box';
/// Get all box names for initialization /// Get all box names for initialization
static List<String> get allBoxes => [ static List<String> get allBoxes => [
userBox, userBox,
@@ -73,6 +76,7 @@ class HiveBoxNames {
rewardsBox, rewardsBox,
cityBox, cityBox,
wardBox, wardBox,
orderStatusBox,
settingsBox, settingsBox,
cacheBox, cacheBox,
syncStateBox, syncStateBox,
@@ -134,8 +138,9 @@ class HiveTypeIds {
static const int addressModel = 30; static const int addressModel = 30;
static const int cityModel = 31; static const int cityModel = 31;
static const int wardModel = 32; static const int wardModel = 32;
static const int orderStatusModel = 62;
// Enums (33-62) // Enums (33-61)
static const int userRole = 33; static const int userRole = 33;
static const int userStatus = 34; static const int userStatus = 34;
static const int loyaltyTier = 35; static const int loyaltyTier = 35;

View File

@@ -168,6 +168,9 @@ class HiveService {
// Location boxes (non-sensitive) - caches cities and wards for address forms // Location boxes (non-sensitive) - caches cities and wards for address forms
Hive.openBox<dynamic>(HiveBoxNames.cityBox), Hive.openBox<dynamic>(HiveBoxNames.cityBox),
Hive.openBox<dynamic>(HiveBoxNames.wardBox), Hive.openBox<dynamic>(HiveBoxNames.wardBox),
// Order status box (non-sensitive) - caches order status list from API
Hive.openBox<dynamic>(HiveBoxNames.orderStatusBox),
]); ]);
// Open potentially encrypted boxes (sensitive data) // Open potentially encrypted boxes (sensitive data)

View File

@@ -0,0 +1,141 @@
/// Status Color Enum
///
/// Defines status types with their associated color values.
/// Used for status badges, alerts, and other UI elements that need
/// consistent color coding across the app.
library;
import 'package:flutter/material.dart';
/// Status Color Enum
///
/// Each status type has an associated color value.
enum StatusColor {
/// Warning status - Yellow/Orange
/// Used for cautionary states, pending actions, or items requiring attention
warning(Color(0xFFFFC107)),
/// Info status - Primary Blue
/// Used for informational states, neutral notifications, or general information
info(Color(0xFF005B9A)),
/// Danger status - Red
/// Used for error states, critical alerts, or destructive actions
danger(Color(0xFFDC3545)),
/// Success status - Green
/// Used for successful operations, completed states, or positive confirmations
success(Color(0xFF28A745)),
/// Secondary status - Light Grey
/// Used for secondary information, disabled states, or less important elements
secondary(Color(0xFFE5E7EB));
/// Constructor
const StatusColor(this.color);
/// The color value associated with this status
final Color color;
/// Get a lighter version of the color (with opacity)
/// Useful for backgrounds and subtle highlights
Color get light => color.withValues(alpha: 0.1);
/// Get a slightly darker version for borders
/// Useful for card borders and dividers
Color get border => color.withValues(alpha: 0.3);
/// Get the color with custom opacity
Color withOpacity(double opacity) => color.withValues(alpha: opacity);
/// Convert from string name (case-insensitive)
///
/// Example:
/// ```dart
/// final status = StatusColor.fromString('warning');
/// // Returns StatusColor.warning
/// ```
static StatusColor? fromString(String name) {
try {
return StatusColor.values.firstWhere(
(e) => e.name.toLowerCase() == name.toLowerCase(),
);
} catch (e) {
return null;
}
}
/// Get status color from order status string
///
/// Maps common order status strings to appropriate colors.
/// Returns null if no mapping exists.
///
/// Example:
/// ```dart
/// final color = StatusColor.fromOrderStatus('Processing');
/// // Returns StatusColor.warning
/// ```
static StatusColor? fromOrderStatus(String status) {
final statusLower = status.toLowerCase();
// Success states
if (statusLower.contains('completed') ||
statusLower.contains('delivered') ||
statusLower.contains('paid') ||
statusLower.contains('approved')) {
return StatusColor.success;
}
// Warning/Pending states
if (statusLower.contains('pending') ||
statusLower.contains('processing') ||
statusLower.contains('shipping') ||
statusLower.contains('reviewing')) {
return StatusColor.warning;
}
// Danger/Error states
if (statusLower.contains('cancelled') ||
statusLower.contains('rejected') ||
statusLower.contains('failed') ||
statusLower.contains('expired')) {
return StatusColor.danger;
}
// Info states
if (statusLower.contains('draft') ||
statusLower.contains('sent') ||
statusLower.contains('viewed')) {
return StatusColor.info;
}
return null;
}
/// Get status color from payment status string
///
/// Maps common payment status strings to appropriate colors.
/// Returns null if no mapping exists.
static StatusColor? fromPaymentStatus(String status) {
final statusLower = status.toLowerCase();
// Success states
if (statusLower.contains('completed') || statusLower.contains('paid')) {
return StatusColor.success;
}
// Warning/Pending states
if (statusLower.contains('pending') || statusLower.contains('processing')) {
return StatusColor.warning;
}
// Danger/Error states
if (statusLower.contains('failed') ||
statusLower.contains('rejected') ||
statusLower.contains('refunded')) {
return StatusColor.danger;
}
return StatusColor.info;
}
}

View File

@@ -59,6 +59,8 @@ class CheckoutPage extends HookConsumerWidget {
// Price negotiation // Price negotiation
final needsNegotiation = useState<bool>(false); final needsNegotiation = useState<bool>(false);
final needsContract = useState(false);
// Watch API provider for payment terms // Watch API provider for payment terms
final paymentTermsListAsync = ref.watch(paymentTermsListProvider); final paymentTermsListAsync = ref.watch(paymentTermsListProvider);
@@ -240,6 +242,40 @@ class CheckoutPage extends HookConsumerWidget {
// Price Negotiation Section // Price Negotiation Section
PriceNegotiationSection(needsNegotiation: needsNegotiation), PriceNegotiationSection(needsNegotiation: needsNegotiation),
const SizedBox(height: AppSpacing.md),
Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
decoration: BoxDecoration(
color: const Color(0xFFFFF8E1),
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: const Color(0xFFFFD54F)),
),
child: Row(
children: [
Checkbox(
value: needsContract.value,
onChanged: (value) {
needsContract.value = value ?? false;
},
activeColor: AppColors.warning,
),
const Expanded(
child: Text(
'Yêu cầu hợp đồng',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF212121),
),
),
),
],
),
),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),

View File

@@ -3,6 +3,7 @@
/// Handles API calls for order-related data. /// Handles API calls for order-related data.
library; library;
import 'package:dio/dio.dart';
import 'package:worker/core/constants/api_constants.dart'; import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/network/dio_client.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/order_status_model.dart';
@@ -194,4 +195,128 @@ class OrderRemoteDataSource {
throw Exception('Failed to generate QR code: $e'); throw Exception('Failed to generate QR code: $e');
} }
} }
/// Upload bill/invoice file
///
/// Calls: POST /api/method/upload_file
/// Form-data: {
/// "file": File,
/// "is_private": "1",
/// "folder": "Home/Attachments",
/// "doctype": "Sales Order",
/// "docname": "SAL-ORD-2025-00058-1",
/// "optimize": "true"
/// }
/// Returns: { "message": { "file_url": "...", "file_name": "...", ... } }
Future<Map<String, dynamic>> uploadBill({
required String filePath,
required String orderId,
}) async {
try {
// Create multipart form data
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(
filePath,
filename: filePath.split('/').last,
),
'is_private': '1',
'folder': 'Home/Attachments',
'doctype': 'Sales Order',
'docname': orderId,
'optimize': 'true',
});
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.uploadFile}',
data: formData,
);
final data = response.data;
if (data == null) {
throw Exception('No data received from uploadBill API');
}
// Extract file info from Frappe response
final message = data['message'] as Map<String, dynamic>?;
if (message == null) {
throw Exception('No message field in uploadBill response');
}
return message;
} catch (e) {
throw Exception('Failed to upload bill: $e');
}
}
/// Get list of orders
///
/// Calls: POST /api/method/building_material.building_material.api.sales_order.get_list
/// Body: { "limit_start": 0, "limit_page_length": 0 }
/// Returns: List of orders
Future<List<Map<String, dynamic>>> getOrdersList({
int limitStart = 0,
int limitPageLength = 0,
}) async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.getOrdersList}',
data: {
'limit_start': limitStart,
'limit_page_length': limitPageLength,
},
);
final data = response.data;
if (data == null) {
throw Exception('No data received from getOrdersList API');
}
// Extract orders list from Frappe response
final message = data['message'];
if (message == null) {
throw Exception('No message field in getOrdersList response');
}
if (message is! List) {
throw Exception('Expected list but got ${message.runtimeType}');
}
return message.cast<Map<String, dynamic>>();
} catch (e) {
throw Exception('Failed to get orders list: $e');
}
}
/// Get order detail
///
/// Calls: POST /api/method/building_material.building_material.api.sales_order.get_detail
/// Body: { "name": "SAL-ORD-2025-00058-1" }
/// Returns: Order details
Future<Map<String, dynamic>> getOrderDetail(String orderName) async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.getOrderDetail}',
data: {'name': orderName},
);
final data = response.data;
if (data == null) {
throw Exception('No data received from getOrderDetail API');
}
// Extract order detail from Frappe response
final message = data['message'];
if (message == null) {
throw Exception('No message field in getOrderDetail response');
}
if (message is! Map<String, dynamic>) {
throw Exception('Expected map but got ${message.runtimeType}');
}
return message;
} catch (e) {
throw Exception('Failed to get order detail: $e');
}
}
} }

View File

@@ -0,0 +1,47 @@
/// Order Status Local Data Source
///
/// Handles local caching of order status list using Hive.
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/orders/data/models/order_status_model.dart';
/// Order Status Local Data Source
class OrderStatusLocalDataSource {
/// Get Hive box for order statuses
Box<dynamic> get _box => Hive.box(HiveBoxNames.orderStatusBox);
/// Save order status list to cache
Future<void> cacheStatusList(List<OrderStatusModel> statuses) async {
// Clear existing cache
await _box.clear();
// Save each status with its index as key
for (final status in statuses) {
await _box.put(status.index, status);
}
}
/// Get cached order status list
List<OrderStatusModel> getCachedStatusList() {
try {
final values = _box.values.whereType<OrderStatusModel>().toList();
// Sort by index
values.sort((a, b) => a.index.compareTo(b.index));
return values;
} catch (e) {
return [];
}
}
/// Check if cache exists and is not empty
bool hasCachedData() {
return _box.isNotEmpty;
}
/// Clear all cached statuses
Future<void> clearCache() async {
await _box.clear();
}
}

View File

@@ -1,215 +0,0 @@
/// Local Data Source: Orders
///
/// Provides mock order data for development and testing.
library;
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:worker/core/database/models/enums.dart';
import 'package:worker/features/orders/data/models/order_model.dart';
/// Orders Local Data Source
///
/// Manages local mock order data.
class OrdersLocalDataSource {
/// Get all mock orders
Future<List<OrderModel>> getAllOrders() async {
try {
debugPrint('[OrdersLocalDataSource] Loading mock orders...');
// Parse mock JSON data
final decoded = jsonDecode(_mockOrdersJson);
if (decoded is! List) {
throw Exception('Invalid JSON format: expected List');
}
final orders = decoded
.map((json) => OrderModel.fromJson(json as Map<String, dynamic>))
.toList();
debugPrint('[OrdersLocalDataSource] Loaded ${orders.length} orders');
return orders;
} catch (e, stackTrace) {
debugPrint('[OrdersLocalDataSource] Error loading orders: $e');
debugPrint('Stack trace: $stackTrace');
rethrow;
}
}
/// Get orders by status
Future<List<OrderModel>> getOrdersByStatus(OrderStatus status) async {
try {
final allOrders = await getAllOrders();
final filtered = allOrders
.where((order) => order.status == status)
.toList();
debugPrint(
'[OrdersLocalDataSource] Filtered ${filtered.length} orders with status: $status',
);
return filtered;
} catch (e) {
debugPrint('[OrdersLocalDataSource] Error filtering orders: $e');
rethrow;
}
}
/// Search orders by order number
Future<List<OrderModel>> searchOrders(String query) async {
try {
if (query.isEmpty) {
return getAllOrders();
}
final allOrders = await getAllOrders();
final filtered = allOrders
.where(
(order) =>
order.orderNumber.toLowerCase().contains(query.toLowerCase()),
)
.toList();
debugPrint(
'[OrdersLocalDataSource] Found ${filtered.length} orders matching "$query"',
);
return filtered;
} catch (e) {
debugPrint('[OrdersLocalDataSource] Error searching orders: $e');
rethrow;
}
}
/// Get order by ID
Future<OrderModel?> getOrderById(String orderId) async {
try {
final allOrders = await getAllOrders();
final order = allOrders.firstWhere(
(order) => order.orderId == orderId,
orElse: () => throw Exception('Order not found: $orderId'),
);
debugPrint('[OrdersLocalDataSource] Found order: ${order.orderNumber}');
return order;
} catch (e) {
debugPrint('[OrdersLocalDataSource] Error getting order: $e');
return null;
}
}
/// Mock orders JSON data
/// Matches the HTML design with 5 sample orders
static const String _mockOrdersJson = '''
[
{
"order_id": "ord_001",
"order_number": "DH001234",
"user_id": "user_001",
"status": "processing",
"total_amount": 12900000,
"discount_amount": 0,
"tax_amount": 0,
"shipping_fee": 0,
"final_amount": 12900000,
"shipping_address": {
"name": "Nguyễn Văn A",
"phone": "0901234567",
"street": "123 Đường Nguyễn Văn Linh",
"district": "Quận 7",
"city": "HCM",
"postal_code": "70000"
},
"expected_delivery_date": "2025-08-06T00:00:00.000Z",
"created_at": "2025-08-03T00:00:00.000Z",
"updated_at": "2025-08-03T00:00:00.000Z"
},
{
"order_id": "ord_002",
"order_number": "DH001233",
"user_id": "user_001",
"status": "completed",
"total_amount": 8500000,
"discount_amount": 0,
"tax_amount": 0,
"shipping_fee": 0,
"final_amount": 8500000,
"shipping_address": {
"name": "Trần Thị B",
"phone": "0912345678",
"street": "456 Đại lộ Bình Dương",
"city": "Thủ Dầu Một, Bình Dương",
"postal_code": "75000"
},
"expected_delivery_date": "2025-06-27T00:00:00.000Z",
"actual_delivery_date": "2025-06-27T00:00:00.000Z",
"created_at": "2025-06-24T00:00:00.000Z",
"updated_at": "2025-06-27T00:00:00.000Z"
},
{
"order_id": "ord_003",
"order_number": "DH001232",
"user_id": "user_001",
"status": "shipped",
"total_amount": 15200000,
"discount_amount": 0,
"tax_amount": 0,
"shipping_fee": 0,
"final_amount": 15200000,
"shipping_address": {
"name": "Lê Văn C",
"phone": "0923456789",
"street": "789 Phố Duy Tân",
"district": "Cầu Giấy",
"city": "Hà Nội",
"postal_code": "10000"
},
"expected_delivery_date": "2025-03-05T00:00:00.000Z",
"created_at": "2025-03-01T00:00:00.000Z",
"updated_at": "2025-03-02T00:00:00.000Z"
},
{
"order_id": "ord_004",
"order_number": "DH001231",
"user_id": "user_001",
"status": "pending",
"total_amount": 6750000,
"discount_amount": 0,
"tax_amount": 0,
"shipping_fee": 0,
"final_amount": 6750000,
"shipping_address": {
"name": "Phạm Thị D",
"phone": "0934567890",
"street": "321 Đường Võ Văn Ngân",
"city": "Thủ Đức, HCM",
"postal_code": "71000"
},
"expected_delivery_date": "2024-11-12T00:00:00.000Z",
"created_at": "2024-11-08T00:00:00.000Z",
"updated_at": "2024-11-08T00:00:00.000Z"
},
{
"order_id": "ord_005",
"order_number": "DH001230",
"user_id": "user_001",
"status": "cancelled",
"total_amount": 3200000,
"discount_amount": 0,
"tax_amount": 0,
"shipping_fee": 0,
"final_amount": 3200000,
"shipping_address": {
"name": "Hoàng Văn E",
"phone": "0945678901",
"street": "654 Đường 3 Tháng 2",
"city": "Rạch Giá, Kiên Giang",
"postal_code": "92000"
},
"expected_delivery_date": "2024-08-04T00:00:00.000Z",
"cancellation_reason": "Khách hàng yêu cầu hủy",
"created_at": "2024-07-30T00:00:00.000Z",
"updated_at": "2024-07-31T00:00:00.000Z"
}
]
''';
}

View File

@@ -0,0 +1,502 @@
/// Order Detail Model
///
/// Data model for order detail API response.
library;
import 'package:worker/features/orders/domain/entities/order_detail.dart';
/// Order Detail Model
class OrderDetailModel {
const OrderDetailModel({
required this.order,
required this.billingAddress,
required this.shippingAddress,
required this.items,
required this.paymentTerms,
required this.timeline,
required this.payments,
required this.invoices,
});
final OrderDetailInfoModel order;
final AddressInfoModel billingAddress;
final AddressInfoModel shippingAddress;
final List<OrderItemDetailModel> items;
final PaymentTermsInfoModel paymentTerms;
final List<TimelineItemModel> timeline;
final List<dynamic> payments;
final List<dynamic> invoices;
/// Create from JSON
factory OrderDetailModel.fromJson(Map<String, dynamic> json) {
return OrderDetailModel(
order: OrderDetailInfoModel.fromJson(
json['order'] as Map<String, dynamic>,
),
billingAddress: AddressInfoModel.fromJson(
json['billing_address'] as Map<String, dynamic>,
),
shippingAddress: AddressInfoModel.fromJson(
json['shipping_address'] as Map<String, dynamic>,
),
items: (json['items'] as List<dynamic>)
.map((item) =>
OrderItemDetailModel.fromJson(item as Map<String, dynamic>))
.toList(),
paymentTerms: PaymentTermsInfoModel.fromJson(
json['payment_terms'] as Map<String, dynamic>,
),
timeline: (json['timeline'] as List<dynamic>)
.map((item) =>
TimelineItemModel.fromJson(item as Map<String, dynamic>))
.toList(),
payments: json['payments'] as List<dynamic>? ?? [],
invoices: json['invoices'] as List<dynamic>? ?? [],
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'order': order.toJson(),
'billing_address': billingAddress.toJson(),
'shipping_address': shippingAddress.toJson(),
'items': items.map((item) => item.toJson()).toList(),
'payment_terms': paymentTerms.toJson(),
'timeline': timeline.map((item) => item.toJson()).toList(),
'payments': payments,
'invoices': invoices,
};
}
/// Convert to domain entity
OrderDetail toEntity() {
return OrderDetail(
order: order.toEntity(),
billingAddress: billingAddress.toEntity(),
shippingAddress: shippingAddress.toEntity(),
items: items.map((item) => item.toEntity()).toList(),
paymentTerms: paymentTerms.toEntity(),
timeline: timeline.map((item) => item.toEntity()).toList(),
payments: payments,
invoices: invoices,
);
}
/// Create from domain entity
factory OrderDetailModel.fromEntity(OrderDetail entity) {
return OrderDetailModel(
order: OrderDetailInfoModel.fromEntity(entity.order),
billingAddress: AddressInfoModel.fromEntity(entity.billingAddress),
shippingAddress: AddressInfoModel.fromEntity(entity.shippingAddress),
items: entity.items
.map((item) => OrderItemDetailModel.fromEntity(item))
.toList(),
paymentTerms: PaymentTermsInfoModel.fromEntity(entity.paymentTerms),
timeline: entity.timeline
.map((item) => TimelineItemModel.fromEntity(item))
.toList(),
payments: entity.payments,
invoices: entity.invoices,
);
}
}
/// Order Detail Info Model
class OrderDetailInfoModel {
const OrderDetailInfoModel({
required this.name,
required this.customer,
required this.transactionDate,
required this.deliveryDate,
required this.status,
required this.statusColor,
required this.totalQty,
required this.total,
required this.grandTotal,
required this.totalRemaining,
required this.description,
required this.contractRequest,
required this.ignorePricingRule,
this.rejectionReason,
required this.isAllowCancel,
});
final String name;
final String customer;
final String transactionDate;
final String deliveryDate;
final String status;
final String statusColor;
final double totalQty;
final double total;
final double grandTotal;
final double totalRemaining;
final String description;
final bool contractRequest;
final bool ignorePricingRule;
final String? rejectionReason;
final bool isAllowCancel;
factory OrderDetailInfoModel.fromJson(Map<String, dynamic> json) {
return OrderDetailInfoModel(
name: json['name'] as String,
customer: json['customer'] as String,
transactionDate: json['transaction_date'] as String,
deliveryDate: json['delivery_date'] as String,
status: json['status'] as String,
statusColor: json['status_color'] as String,
totalQty: (json['total_qty'] as num).toDouble(),
total: (json['total'] as num).toDouble(),
grandTotal: (json['grand_total'] as num).toDouble(),
totalRemaining: (json['total_remaining'] as num).toDouble(),
description: json['description'] as String,
contractRequest: json['contract_request'] as bool,
ignorePricingRule: json['ignore_pricing_rule'] as bool,
rejectionReason: json['rejection_reason'] as String?,
isAllowCancel: json['is_allow_cancel'] as bool,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'customer': customer,
'transaction_date': transactionDate,
'delivery_date': deliveryDate,
'status': status,
'status_color': statusColor,
'total_qty': totalQty,
'total': total,
'grand_total': grandTotal,
'total_remaining': totalRemaining,
'description': description,
'contract_request': contractRequest,
'ignore_pricing_rule': ignorePricingRule,
'rejection_reason': rejectionReason,
'is_allow_cancel': isAllowCancel,
};
}
OrderDetailInfo toEntity() {
return OrderDetailInfo(
name: name,
customer: customer,
transactionDate: transactionDate,
deliveryDate: deliveryDate,
status: status,
statusColor: statusColor,
totalQty: totalQty,
total: total,
grandTotal: grandTotal,
totalRemaining: totalRemaining,
description: description,
contractRequest: contractRequest,
ignorePricingRule: ignorePricingRule,
rejectionReason: rejectionReason,
isAllowCancel: isAllowCancel,
);
}
factory OrderDetailInfoModel.fromEntity(OrderDetailInfo entity) {
return OrderDetailInfoModel(
name: entity.name,
customer: entity.customer,
transactionDate: entity.transactionDate,
deliveryDate: entity.deliveryDate,
status: entity.status,
statusColor: entity.statusColor,
totalQty: entity.totalQty,
total: entity.total,
grandTotal: entity.grandTotal,
totalRemaining: entity.totalRemaining,
description: entity.description,
contractRequest: entity.contractRequest,
ignorePricingRule: entity.ignorePricingRule,
rejectionReason: entity.rejectionReason,
isAllowCancel: entity.isAllowCancel,
);
}
}
/// Address Info Model
class AddressInfoModel {
const AddressInfoModel({
required this.name,
required this.addressTitle,
required this.addressLine1,
required this.phone,
required this.email,
this.fax,
required this.taxCode,
required this.cityCode,
required this.wardCode,
required this.cityName,
required this.wardName,
required this.isAllowEdit,
});
final String name;
final String addressTitle;
final String addressLine1;
final String phone;
final String email;
final String? fax;
final String taxCode;
final String cityCode;
final String wardCode;
final String cityName;
final String wardName;
final bool isAllowEdit;
factory AddressInfoModel.fromJson(Map<String, dynamic> json) {
return AddressInfoModel(
name: json['name'] as String,
addressTitle: json['address_title'] as String,
addressLine1: json['address_line1'] as String,
phone: json['phone'] as String,
email: json['email'] as String,
fax: json['fax'] as String?,
taxCode: json['tax_code'] as String,
cityCode: json['city_code'] as String,
wardCode: json['ward_code'] as String,
cityName: json['city_name'] as String,
wardName: json['ward_name'] as String,
isAllowEdit: json['is_allow_edit'] as bool,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'address_title': addressTitle,
'address_line1': addressLine1,
'phone': phone,
'email': email,
'fax': fax,
'tax_code': taxCode,
'city_code': cityCode,
'ward_code': wardCode,
'city_name': cityName,
'ward_name': wardName,
'is_allow_edit': isAllowEdit,
};
}
AddressInfo toEntity() {
return AddressInfo(
name: name,
addressTitle: addressTitle,
addressLine1: addressLine1,
phone: phone,
email: email,
fax: fax,
taxCode: taxCode,
cityCode: cityCode,
wardCode: wardCode,
cityName: cityName,
wardName: wardName,
isAllowEdit: isAllowEdit,
);
}
factory AddressInfoModel.fromEntity(AddressInfo entity) {
return AddressInfoModel(
name: entity.name,
addressTitle: entity.addressTitle,
addressLine1: entity.addressLine1,
phone: entity.phone,
email: entity.email,
fax: entity.fax,
taxCode: entity.taxCode,
cityCode: entity.cityCode,
wardCode: entity.wardCode,
cityName: entity.cityName,
wardName: entity.wardName,
isAllowEdit: entity.isAllowEdit,
);
}
}
/// Order Item Detail Model
class OrderItemDetailModel {
const OrderItemDetailModel({
required this.name,
required this.itemCode,
required this.itemName,
required this.description,
required this.qtyEntered,
required this.qtyOfSm,
required this.qtyOfNos,
required this.conversionFactor,
required this.price,
required this.totalAmount,
required this.deliveryDate,
this.thumbnail,
});
final String name;
final String itemCode;
final String itemName;
final String description;
final double qtyEntered;
final double qtyOfSm;
final double qtyOfNos;
final double conversionFactor;
final double price;
final double totalAmount;
final String deliveryDate;
final String? thumbnail;
factory OrderItemDetailModel.fromJson(Map<String, dynamic> json) {
return OrderItemDetailModel(
name: json['name'] as String,
itemCode: json['item_code'] as String,
itemName: json['item_name'] as String,
description: json['description'] as String,
qtyEntered: (json['qty_entered'] as num).toDouble(),
qtyOfSm: (json['qty_of_sm'] as num).toDouble(),
qtyOfNos: (json['qty_of_nos'] as num).toDouble(),
conversionFactor: (json['conversion_factor'] as num).toDouble(),
price: (json['price'] as num).toDouble(),
totalAmount: (json['total_amount'] as num).toDouble(),
deliveryDate: json['delivery_date'] as String,
thumbnail: json['thumbnail'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'item_code': itemCode,
'item_name': itemName,
'description': description,
'qty_entered': qtyEntered,
'qty_of_sm': qtyOfSm,
'qty_of_nos': qtyOfNos,
'conversion_factor': conversionFactor,
'price': price,
'total_amount': totalAmount,
'delivery_date': deliveryDate,
'thumbnail': thumbnail,
};
}
OrderItemDetail toEntity() {
return OrderItemDetail(
name: name,
itemCode: itemCode,
itemName: itemName,
description: description,
qtyEntered: qtyEntered,
qtyOfSm: qtyOfSm,
qtyOfNos: qtyOfNos,
conversionFactor: conversionFactor,
price: price,
totalAmount: totalAmount,
deliveryDate: deliveryDate,
thumbnail: thumbnail,
);
}
factory OrderItemDetailModel.fromEntity(OrderItemDetail entity) {
return OrderItemDetailModel(
name: entity.name,
itemCode: entity.itemCode,
itemName: entity.itemName,
description: entity.description,
qtyEntered: entity.qtyEntered,
qtyOfSm: entity.qtyOfSm,
qtyOfNos: entity.qtyOfNos,
conversionFactor: entity.conversionFactor,
price: entity.price,
totalAmount: entity.totalAmount,
deliveryDate: entity.deliveryDate,
thumbnail: entity.thumbnail,
);
}
}
/// Payment Terms Info Model
class PaymentTermsInfoModel {
const PaymentTermsInfoModel({
required this.name,
required this.description,
});
final String name;
final String description;
factory PaymentTermsInfoModel.fromJson(Map<String, dynamic> json) {
return PaymentTermsInfoModel(
name: json['name'] as String,
description: json['description'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'description': description,
};
}
PaymentTermsInfo toEntity() {
return PaymentTermsInfo(
name: name,
description: description,
);
}
factory PaymentTermsInfoModel.fromEntity(PaymentTermsInfo entity) {
return PaymentTermsInfoModel(
name: entity.name,
description: entity.description,
);
}
}
/// Timeline Item Model
class TimelineItemModel {
const TimelineItemModel({
required this.label,
this.value,
required this.status,
});
final String label;
final String? value;
final String status;
factory TimelineItemModel.fromJson(Map<String, dynamic> json) {
return TimelineItemModel(
label: json['label'] as String,
value: json['value'] as String?,
status: json['status'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'label': label,
'value': value,
'status': status,
};
}
TimelineItem toEntity() {
return TimelineItem(
label: label,
value: value,
status: status,
);
}
factory TimelineItemModel.fromEntity(TimelineItem entity) {
return TimelineItemModel(
label: entity.label,
value: entity.value,
status: entity.status,
);
}
}

View File

@@ -1,161 +1,117 @@
import 'dart:convert';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart'; import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/core/database/models/enums.dart'; import 'package:worker/features/orders/domain/entities/order.dart';
part 'order_model.g.dart'; part 'order_model.g.dart';
/// Order Model - Type ID: 6 /// Order Model - Type ID: 6
///
/// Simplified model matching API response structure
@HiveType(typeId: HiveTypeIds.orderModel) @HiveType(typeId: HiveTypeIds.orderModel)
class OrderModel extends HiveObject { class OrderModel extends HiveObject {
/// Order ID/Number (from API 'name' field)
@HiveField(0)
final String name;
/// Transaction date
@HiveField(1)
final String transactionDate;
/// Expected delivery date
@HiveField(2)
final String deliveryDate;
/// Delivery address
@HiveField(3)
final String address;
/// Grand total amount
@HiveField(4)
final double grandTotal;
/// Status label (Vietnamese)
@HiveField(5)
final String status;
/// Status color (Warning, Success, Danger, Info, Secondary)
@HiveField(6)
final String statusColor;
OrderModel({ OrderModel({
required this.orderId, required this.name,
required this.orderNumber, required this.transactionDate,
required this.userId, required this.deliveryDate,
required this.address,
required this.grandTotal,
required this.status, required this.status,
required this.totalAmount, required this.statusColor,
required this.discountAmount,
required this.taxAmount,
required this.shippingFee,
required this.finalAmount,
this.shippingAddress,
this.billingAddress,
this.expectedDeliveryDate,
this.actualDeliveryDate,
this.notes,
this.cancellationReason,
this.erpnextSalesOrder,
required this.createdAt,
this.updatedAt,
}); });
@HiveField(0) /// Create from JSON (API response)
final String orderId;
@HiveField(1)
final String orderNumber;
@HiveField(2)
final String userId;
@HiveField(3)
final OrderStatus status;
@HiveField(4)
final double totalAmount;
@HiveField(5)
final double discountAmount;
@HiveField(6)
final double taxAmount;
@HiveField(7)
final double shippingFee;
@HiveField(8)
final double finalAmount;
@HiveField(9)
final String? shippingAddress;
@HiveField(10)
final String? billingAddress;
@HiveField(11)
final DateTime? expectedDeliveryDate;
@HiveField(12)
final DateTime? actualDeliveryDate;
@HiveField(13)
final String? notes;
@HiveField(14)
final String? cancellationReason;
@HiveField(15)
final String? erpnextSalesOrder;
@HiveField(16)
final DateTime createdAt;
@HiveField(17)
final DateTime? updatedAt;
factory OrderModel.fromJson(Map<String, dynamic> json) { factory OrderModel.fromJson(Map<String, dynamic> json) {
return OrderModel( return OrderModel(
orderId: json['order_id'] as String, name: json['name'] as String? ?? '',
orderNumber: json['order_number'] as String, transactionDate: json['transaction_date'] as String? ?? '',
userId: json['user_id'] as String, deliveryDate: json['delivery_date'] as String? ?? '',
status: OrderStatus.values.firstWhere((e) => e.name == json['status']), address: json['address'] as String? ?? '',
totalAmount: (json['total_amount'] as num).toDouble(), grandTotal: (json['grand_total'] as num?)?.toDouble() ?? 0.0,
discountAmount: (json['discount_amount'] as num).toDouble(), status: json['status'] as String? ?? '',
taxAmount: (json['tax_amount'] as num).toDouble(), statusColor: json['status_color'] as String? ?? 'Secondary',
shippingFee: (json['shipping_fee'] as num).toDouble(),
finalAmount: (json['final_amount'] as num).toDouble(),
shippingAddress: json['shipping_address'] != null
? jsonEncode(json['shipping_address'])
: null,
billingAddress: json['billing_address'] != null
? jsonEncode(json['billing_address'])
: null,
expectedDeliveryDate: json['expected_delivery_date'] != null
? DateTime.parse(json['expected_delivery_date']?.toString() ?? '')
: null,
actualDeliveryDate: json['actual_delivery_date'] != null
? DateTime.parse(json['actual_delivery_date']?.toString() ?? '')
: null,
notes: json['notes'] as String?,
cancellationReason: json['cancellation_reason'] as String?,
erpnextSalesOrder: json['erpnext_sales_order'] as String?,
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at']?.toString() ?? '')
: null,
); );
} }
/// Convert to JSON
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'order_id': orderId, 'name': name,
'order_number': orderNumber, 'transaction_date': transactionDate,
'user_id': userId, 'delivery_date': deliveryDate,
'status': status.name, 'address': address,
'total_amount': totalAmount, 'grand_total': grandTotal,
'discount_amount': discountAmount, 'status': status,
'tax_amount': taxAmount, 'status_color': statusColor,
'shipping_fee': shippingFee,
'final_amount': finalAmount,
'shipping_address': shippingAddress != null
? jsonDecode(shippingAddress!)
: null,
'billing_address': billingAddress != null
? jsonDecode(billingAddress!)
: null,
'expected_delivery_date': expectedDeliveryDate?.toIso8601String(),
'actual_delivery_date': actualDeliveryDate?.toIso8601String(),
'notes': notes,
'cancellation_reason': cancellationReason,
'erpnext_sales_order': erpnextSalesOrder,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
}; };
Map<String, dynamic>? get shippingAddressMap { /// Get parsed transaction date
if (shippingAddress == null) return null; DateTime? get transactionDateTime {
try { try {
return jsonDecode(shippingAddress!) as Map<String, dynamic>; return DateTime.parse(transactionDate);
} catch (e) { } catch (e) {
return null; return null;
} }
} }
Map<String, dynamic>? get billingAddressMap { /// Get parsed delivery date
if (billingAddress == null) return null; DateTime? get deliveryDateTime {
try { try {
return jsonDecode(billingAddress!) as Map<String, dynamic>; return DateTime.parse(deliveryDate);
} catch (e) { } catch (e) {
return null; return null;
} }
} }
/// Convert to domain entity
Order toEntity() {
return Order(
name: name,
transactionDate: transactionDate,
deliveryDate: deliveryDate,
address: address,
grandTotal: grandTotal,
status: status,
statusColor: statusColor,
);
}
/// Create from domain entity
factory OrderModel.fromEntity(Order entity) {
return OrderModel(
name: entity.name,
transactionDate: entity.transactionDate,
deliveryDate: entity.deliveryDate,
address: entity.address,
grandTotal: entity.grandTotal,
status: entity.status,
statusColor: entity.statusColor,
);
}
} }

View File

@@ -17,67 +17,34 @@ class OrderModelAdapter extends TypeAdapter<OrderModel> {
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
}; };
return OrderModel( return OrderModel(
orderId: fields[0] as String, name: fields[0] as String,
orderNumber: fields[1] as String, transactionDate: fields[1] as String,
userId: fields[2] as String, deliveryDate: fields[2] as String,
status: fields[3] as OrderStatus, address: fields[3] as String,
totalAmount: (fields[4] as num).toDouble(), grandTotal: (fields[4] as num).toDouble(),
discountAmount: (fields[5] as num).toDouble(), status: fields[5] as String,
taxAmount: (fields[6] as num).toDouble(), statusColor: fields[6] as String,
shippingFee: (fields[7] as num).toDouble(),
finalAmount: (fields[8] as num).toDouble(),
shippingAddress: fields[9] as String?,
billingAddress: fields[10] as String?,
expectedDeliveryDate: fields[11] as DateTime?,
actualDeliveryDate: fields[12] as DateTime?,
notes: fields[13] as String?,
cancellationReason: fields[14] as String?,
erpnextSalesOrder: fields[15] as String?,
createdAt: fields[16] as DateTime,
updatedAt: fields[17] as DateTime?,
); );
} }
@override @override
void write(BinaryWriter writer, OrderModel obj) { void write(BinaryWriter writer, OrderModel obj) {
writer writer
..writeByte(18)
..writeByte(0)
..write(obj.orderId)
..writeByte(1)
..write(obj.orderNumber)
..writeByte(2)
..write(obj.userId)
..writeByte(3)
..write(obj.status)
..writeByte(4)
..write(obj.totalAmount)
..writeByte(5)
..write(obj.discountAmount)
..writeByte(6)
..write(obj.taxAmount)
..writeByte(7) ..writeByte(7)
..write(obj.shippingFee) ..writeByte(0)
..writeByte(8) ..write(obj.name)
..write(obj.finalAmount) ..writeByte(1)
..writeByte(9) ..write(obj.transactionDate)
..write(obj.shippingAddress) ..writeByte(2)
..writeByte(10) ..write(obj.deliveryDate)
..write(obj.billingAddress) ..writeByte(3)
..writeByte(11) ..write(obj.address)
..write(obj.expectedDeliveryDate) ..writeByte(4)
..writeByte(12) ..write(obj.grandTotal)
..write(obj.actualDeliveryDate) ..writeByte(5)
..writeByte(13) ..write(obj.status)
..write(obj.notes) ..writeByte(6)
..writeByte(14) ..write(obj.statusColor);
..write(obj.cancellationReason)
..writeByte(15)
..write(obj.erpnextSalesOrder)
..writeByte(16)
..write(obj.createdAt)
..writeByte(17)
..write(obj.updatedAt);
} }
@override @override

View File

@@ -1,19 +1,30 @@
/// Order Status Model /// Order Status Model
/// ///
/// Data model for order status from API responses. /// Data model for order status from API responses with Hive caching.
library; library;
import 'package:equatable/equatable.dart'; import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/orders/domain/entities/order_status.dart'; import 'package:worker/features/orders/domain/entities/order_status.dart';
/// Order Status Model part 'order_status_model.g.dart';
class OrderStatusModel extends Equatable {
/// Order Status Model - Type ID: 62
@HiveType(typeId: HiveTypeIds.orderStatusModel)
class OrderStatusModel extends HiveObject {
@HiveField(0)
final String status; final String status;
@HiveField(1)
final String label; final String label;
@HiveField(2)
final String color; final String color;
@HiveField(3)
final int index; final int index;
const OrderStatusModel({ OrderStatusModel({
required this.status, required this.status,
required this.label, required this.label,
required this.color, required this.color,
@@ -59,7 +70,4 @@ class OrderStatusModel extends Equatable {
index: entity.index, index: entity.index,
); );
} }
@override
List<Object?> get props => [status, label, color, index];
} }

View File

@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'order_status_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class OrderStatusModelAdapter extends TypeAdapter<OrderStatusModel> {
@override
final typeId = 62;
@override
OrderStatusModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return OrderStatusModel(
status: fields[0] as String,
label: fields[1] as String,
color: fields[2] as String,
index: (fields[3] as num).toInt(),
);
}
@override
void write(BinaryWriter writer, OrderStatusModel obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.status)
..writeByte(1)
..write(obj.label)
..writeByte(2)
..write(obj.color)
..writeByte(3)
..write(obj.index);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is OrderStatusModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -4,22 +4,80 @@
library; library;
import 'package:worker/features/orders/data/datasources/order_remote_datasource.dart'; import 'package:worker/features/orders/data/datasources/order_remote_datasource.dart';
import 'package:worker/features/orders/data/datasources/order_status_local_datasource.dart';
import 'package:worker/features/orders/data/models/order_detail_model.dart';
import 'package:worker/features/orders/data/models/order_model.dart';
import 'package:worker/features/orders/domain/entities/order.dart';
import 'package:worker/features/orders/domain/entities/order_detail.dart';
import 'package:worker/features/orders/domain/entities/order_status.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/entities/payment_term.dart';
import 'package:worker/features/orders/domain/repositories/order_repository.dart'; import 'package:worker/features/orders/domain/repositories/order_repository.dart';
/// Order Repository Implementation /// Order Repository Implementation
class OrderRepositoryImpl implements OrderRepository { class OrderRepositoryImpl implements OrderRepository {
const OrderRepositoryImpl(this._remoteDataSource); const OrderRepositoryImpl(
this._remoteDataSource,
this._statusLocalDataSource,
);
final OrderRemoteDataSource _remoteDataSource; final OrderRemoteDataSource _remoteDataSource;
final OrderStatusLocalDataSource _statusLocalDataSource;
@override
Future<List<Order>> getOrdersList({
int limitStart = 0,
int limitPageLength = 0,
}) async {
try {
final ordersData = await _remoteDataSource.getOrdersList(
limitStart: limitStart,
limitPageLength: limitPageLength,
);
// Convert JSON → Model → Entity
return ordersData
.map((json) => OrderModel.fromJson(json).toEntity())
.toList();
} catch (e) {
throw Exception('Failed to get orders list: $e');
}
}
@override
Future<OrderDetail> getOrderDetail(String orderId) async {
try {
final detailData = await _remoteDataSource.getOrderDetail(orderId);
// Convert JSON → Model → Entity
return OrderDetailModel.fromJson(detailData).toEntity();
} catch (e) {
throw Exception('Failed to get order detail: $e');
}
}
@override @override
Future<List<OrderStatus>> getOrderStatusList() async { Future<List<OrderStatus>> getOrderStatusList() async {
try { try {
// Try to get from cache first
if (_statusLocalDataSource.hasCachedData()) {
final cachedModels = _statusLocalDataSource.getCachedStatusList();
if (cachedModels.isNotEmpty) {
return cachedModels.map((model) => model.toEntity()).toList();
}
}
// Fetch from API
final models = await _remoteDataSource.getOrderStatusList(); final models = await _remoteDataSource.getOrderStatusList();
// Cache the results
await _statusLocalDataSource.cacheStatusList(models);
// Return entities
return models.map((model) => model.toEntity()).toList(); return models.map((model) => model.toEntity()).toList();
} catch (e) { } catch (e) {
// If API fails, try to return cached data
final cachedModels = _statusLocalDataSource.getCachedStatusList();
if (cachedModels.isNotEmpty) {
return cachedModels.map((model) => model.toEntity()).toList();
}
throw Exception('Failed to get order status list: $e'); throw Exception('Failed to get order status list: $e');
} }
} }
@@ -65,4 +123,19 @@ class OrderRepositoryImpl implements OrderRepository {
throw Exception('Failed to generate QR code: $e'); throw Exception('Failed to generate QR code: $e');
} }
} }
@override
Future<Map<String, dynamic>> uploadBill({
required String filePath,
required String orderId,
}) async {
try {
return await _remoteDataSource.uploadBill(
filePath: filePath,
orderId: orderId,
);
} catch (e) {
throw Exception('Failed to upload bill: $e');
}
}
} }

View File

@@ -1,321 +1,97 @@
/// Domain Entity: Order /// Domain Entity: Order
/// ///
/// Represents a customer order. /// Represents a customer order (simplified to match API structure).
library; library;
/// Order status enum import 'package:equatable/equatable.dart';
enum OrderStatus {
/// Order has been created but not confirmed
draft,
/// Order has been confirmed
confirmed,
/// Order is being processed
processing,
/// Order is ready for shipping
ready,
/// Order has been shipped
shipped,
/// Order has been delivered
delivered,
/// Order has been completed
completed,
/// Order has been cancelled
cancelled,
/// Order has been returned
returned;
/// Get display name for status
String get displayName {
switch (this) {
case OrderStatus.draft:
return 'Draft';
case OrderStatus.confirmed:
return 'Confirmed';
case OrderStatus.processing:
return 'Processing';
case OrderStatus.ready:
return 'Ready';
case OrderStatus.shipped:
return 'Shipped';
case OrderStatus.delivered:
return 'Delivered';
case OrderStatus.completed:
return 'Completed';
case OrderStatus.cancelled:
return 'Cancelled';
case OrderStatus.returned:
return 'Returned';
}
}
}
/// Address information
class Address {
/// Recipient name
final String? name;
/// Phone number
final String? phone;
/// Street address
final String? street;
/// Ward/commune
final String? ward;
/// District
final String? district;
/// City/province
final String? city;
/// Postal code
final String? postalCode;
const Address({
this.name,
this.phone,
this.street,
this.ward,
this.district,
this.city,
this.postalCode,
});
/// Get full address string
String get fullAddress {
final parts = [
street,
ward,
district,
city,
postalCode,
].where((part) => part != null && part.isNotEmpty).toList();
return parts.join(', ');
}
/// Create from JSON map
factory Address.fromJson(Map<String, dynamic> json) {
return Address(
name: json['name'] as String?,
phone: json['phone'] as String?,
street: json['street'] as String?,
ward: json['ward'] as String?,
district: json['district'] as String?,
city: json['city'] as String?,
postalCode: json['postal_code'] as String?,
);
}
/// Convert to JSON map
Map<String, dynamic> toJson() {
return {
'name': name,
'phone': phone,
'street': street,
'ward': ward,
'district': district,
'city': city,
'postal_code': postalCode,
};
}
}
/// Order Entity /// Order Entity
/// ///
/// Contains complete order information: /// Pure domain entity matching API response structure
/// - Order identification class Order extends Equatable {
/// - Customer details /// Order ID/Number
/// - Pricing and discounts final String name;
/// - Shipping information
/// - Status tracking
class Order {
/// Unique order identifier
final String orderId;
/// Human-readable order number /// Transaction date (ISO format string)
final String orderNumber; final String transactionDate;
/// User ID who placed the order /// Expected delivery date (ISO format string)
final String userId; final String deliveryDate;
/// Current order status /// Delivery address
final OrderStatus status; final String address;
/// Total order amount before discounts /// Grand total amount
final double totalAmount; final double grandTotal;
/// Discount amount applied /// Status label (Vietnamese)
final double discountAmount; final String status;
/// Tax amount /// Status color (Warning, Success, Danger, Info, Secondary)
final double taxAmount; final String statusColor;
/// Shipping fee
final double shippingFee;
/// Final amount to pay
final double finalAmount;
/// Shipping address
final Address? shippingAddress;
/// Billing address
final Address? billingAddress;
/// Expected delivery date
final DateTime? expectedDeliveryDate;
/// Actual delivery date
final DateTime? actualDeliveryDate;
/// Order notes
final String? notes;
/// Cancellation reason
final String? cancellationReason;
/// ERPNext sales order reference
final String? erpnextSalesOrder;
/// Order creation timestamp
final DateTime createdAt;
/// Last update timestamp
final DateTime updatedAt;
const Order({ const Order({
required this.orderId, required this.name,
required this.orderNumber, required this.transactionDate,
required this.userId, required this.deliveryDate,
required this.address,
required this.grandTotal,
required this.status, required this.status,
required this.totalAmount, required this.statusColor,
required this.discountAmount,
required this.taxAmount,
required this.shippingFee,
required this.finalAmount,
this.shippingAddress,
this.billingAddress,
this.expectedDeliveryDate,
this.actualDeliveryDate,
this.notes,
this.cancellationReason,
this.erpnextSalesOrder,
required this.createdAt,
required this.updatedAt,
}); });
/// Check if order is active (not cancelled or completed) /// Get parsed transaction date
bool get isActive => DateTime? get transactionDateTime {
status != OrderStatus.cancelled && try {
status != OrderStatus.completed && return DateTime.parse(transactionDate);
status != OrderStatus.returned; } catch (e) {
return null;
}
}
/// Check if order can be cancelled /// Get parsed delivery date
bool get canBeCancelled => DateTime? get deliveryDateTime {
status == OrderStatus.draft || try {
status == OrderStatus.confirmed || return DateTime.parse(deliveryDate);
status == OrderStatus.processing; } catch (e) {
return null;
/// Check if order is delivered }
bool get isDelivered =>
status == OrderStatus.delivered || status == OrderStatus.completed;
/// Check if order is cancelled
bool get isCancelled => status == OrderStatus.cancelled;
/// Get discount percentage
double get discountPercentage {
if (totalAmount == 0) return 0;
return (discountAmount / totalAmount) * 100;
} }
/// Copy with method for immutability /// Copy with method for immutability
Order copyWith({ Order copyWith({
String? orderId, String? name,
String? orderNumber, String? transactionDate,
String? userId, String? deliveryDate,
OrderStatus? status, String? address,
double? totalAmount, double? grandTotal,
double? discountAmount, String? status,
double? taxAmount, String? statusColor,
double? shippingFee,
double? finalAmount,
Address? shippingAddress,
Address? billingAddress,
DateTime? expectedDeliveryDate,
DateTime? actualDeliveryDate,
String? notes,
String? cancellationReason,
String? erpnextSalesOrder,
DateTime? createdAt,
DateTime? updatedAt,
}) { }) {
return Order( return Order(
orderId: orderId ?? this.orderId, name: name ?? this.name,
orderNumber: orderNumber ?? this.orderNumber, transactionDate: transactionDate ?? this.transactionDate,
userId: userId ?? this.userId, deliveryDate: deliveryDate ?? this.deliveryDate,
address: address ?? this.address,
grandTotal: grandTotal ?? this.grandTotal,
status: status ?? this.status, status: status ?? this.status,
totalAmount: totalAmount ?? this.totalAmount, statusColor: statusColor ?? this.statusColor,
discountAmount: discountAmount ?? this.discountAmount,
taxAmount: taxAmount ?? this.taxAmount,
shippingFee: shippingFee ?? this.shippingFee,
finalAmount: finalAmount ?? this.finalAmount,
shippingAddress: shippingAddress ?? this.shippingAddress,
billingAddress: billingAddress ?? this.billingAddress,
expectedDeliveryDate: expectedDeliveryDate ?? this.expectedDeliveryDate,
actualDeliveryDate: actualDeliveryDate ?? this.actualDeliveryDate,
notes: notes ?? this.notes,
cancellationReason: cancellationReason ?? this.cancellationReason,
erpnextSalesOrder: erpnextSalesOrder ?? this.erpnextSalesOrder,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
); );
} }
@override @override
bool operator ==(Object other) { List<Object?> get props => [
if (identical(this, other)) return true; name,
transactionDate,
return other is Order && deliveryDate,
other.orderId == orderId && address,
other.orderNumber == orderNumber && grandTotal,
other.userId == userId &&
other.status == status &&
other.totalAmount == totalAmount &&
other.discountAmount == discountAmount &&
other.taxAmount == taxAmount &&
other.shippingFee == shippingFee &&
other.finalAmount == finalAmount;
}
@override
int get hashCode {
return Object.hash(
orderId,
orderNumber,
userId,
status, status,
totalAmount, statusColor,
discountAmount, ];
taxAmount,
shippingFee,
finalAmount,
);
}
@override @override
String toString() { String toString() {
return 'Order(orderId: $orderId, orderNumber: $orderNumber, status: $status, ' return 'Order(name: $name, status: $status, grandTotal: $grandTotal, transactionDate: $transactionDate)';
'finalAmount: $finalAmount, createdAt: $createdAt)';
} }
} }

View File

@@ -0,0 +1,221 @@
/// Order Detail Entity
///
/// Complete order detail information including addresses, items, timeline, etc.
library;
import 'package:equatable/equatable.dart';
/// Order Detail Entity
class OrderDetail extends Equatable {
const OrderDetail({
required this.order,
required this.billingAddress,
required this.shippingAddress,
required this.items,
required this.paymentTerms,
required this.timeline,
required this.payments,
required this.invoices,
});
final OrderDetailInfo order;
final AddressInfo billingAddress;
final AddressInfo shippingAddress;
final List<OrderItemDetail> items;
final PaymentTermsInfo paymentTerms;
final List<TimelineItem> timeline;
final List<dynamic> payments; // Payment entities can be added later
final List<dynamic> invoices; // Invoice entities can be added later
@override
List<Object?> get props => [
order,
billingAddress,
shippingAddress,
items,
paymentTerms,
timeline,
payments,
invoices,
];
}
/// Order Detail Info
class OrderDetailInfo extends Equatable {
const OrderDetailInfo({
required this.name,
required this.customer,
required this.transactionDate,
required this.deliveryDate,
required this.status,
required this.statusColor,
required this.totalQty,
required this.total,
required this.grandTotal,
required this.totalRemaining,
required this.description,
required this.contractRequest,
required this.ignorePricingRule,
this.rejectionReason,
required this.isAllowCancel,
});
final String name;
final String customer;
final String transactionDate;
final String deliveryDate;
final String status;
final String statusColor;
final double totalQty;
final double total;
final double grandTotal;
final double totalRemaining;
final String description;
final bool contractRequest;
final bool ignorePricingRule;
final String? rejectionReason;
final bool isAllowCancel;
@override
List<Object?> get props => [
name,
customer,
transactionDate,
deliveryDate,
status,
statusColor,
totalQty,
total,
grandTotal,
totalRemaining,
description,
contractRequest,
ignorePricingRule,
rejectionReason,
isAllowCancel,
];
}
/// Address Info
class AddressInfo extends Equatable {
const AddressInfo({
required this.name,
required this.addressTitle,
required this.addressLine1,
required this.phone,
required this.email,
this.fax,
required this.taxCode,
required this.cityCode,
required this.wardCode,
required this.cityName,
required this.wardName,
required this.isAllowEdit,
});
final String name;
final String addressTitle;
final String addressLine1;
final String phone;
final String email;
final String? fax;
final String taxCode;
final String cityCode;
final String wardCode;
final String cityName;
final String wardName;
final bool isAllowEdit;
@override
List<Object?> get props => [
name,
addressTitle,
addressLine1,
phone,
email,
fax,
taxCode,
cityCode,
wardCode,
cityName,
wardName,
isAllowEdit,
];
}
/// Order Item Detail
class OrderItemDetail extends Equatable {
const OrderItemDetail({
required this.name,
required this.itemCode,
required this.itemName,
required this.description,
required this.qtyEntered,
required this.qtyOfSm,
required this.qtyOfNos,
required this.conversionFactor,
required this.price,
required this.totalAmount,
required this.deliveryDate,
this.thumbnail,
});
final String name;
final String itemCode;
final String itemName;
final String description;
final double qtyEntered;
final double qtyOfSm;
final double qtyOfNos;
final double conversionFactor;
final double price;
final double totalAmount;
final String deliveryDate;
final String? thumbnail;
@override
List<Object?> get props => [
name,
itemCode,
itemName,
description,
qtyEntered,
qtyOfSm,
qtyOfNos,
conversionFactor,
price,
totalAmount,
deliveryDate,
thumbnail,
];
}
/// Payment Terms Info
class PaymentTermsInfo extends Equatable {
const PaymentTermsInfo({
required this.name,
required this.description,
});
final String name;
final String description;
@override
List<Object?> get props => [name, description];
}
/// Timeline Item
class TimelineItem extends Equatable {
const TimelineItem({
required this.label,
this.value,
required this.status,
});
final String label;
final String? value;
final String status; // Success, Warning, Secondary, etc.
@override
List<Object?> get props => [label, value, status];
}

View File

@@ -3,11 +3,22 @@
/// Defines the contract for order-related data operations. /// Defines the contract for order-related data operations.
library; library;
import 'package:worker/features/orders/domain/entities/order.dart';
import 'package:worker/features/orders/domain/entities/order_detail.dart';
import 'package:worker/features/orders/domain/entities/order_status.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/entities/payment_term.dart';
/// Order Repository Interface /// Order Repository Interface
abstract class OrderRepository { abstract class OrderRepository {
/// Get list of orders
Future<List<Order>> getOrdersList({
int limitStart = 0,
int limitPageLength = 0,
});
/// Get order detail by ID
Future<OrderDetail> getOrderDetail(String orderId);
/// Get list of available order statuses /// Get list of available order statuses
Future<List<OrderStatus>> getOrderStatusList(); Future<List<OrderStatus>> getOrderStatusList();
@@ -26,4 +37,10 @@ abstract class OrderRepository {
/// Generate QR code for payment /// Generate QR code for payment
Future<Map<String, dynamic>> generateQrCode(String orderId); Future<Map<String, dynamic>> generateQrCode(String orderId);
/// Upload bill/invoice file
Future<Map<String, dynamic>> uploadBill({
required String filePath,
required String orderId,
});
} }

View File

@@ -3,14 +3,17 @@
/// Displays detailed order information including status timeline, delivery info, and products. /// Displays detailed order information including status timeline, delivery info, and products.
library; library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/database/models/enums.dart'; import 'package:worker/core/enums/status_color.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/orders/domain/entities/order_detail.dart';
import 'package:worker/features/orders/presentation/providers/orders_provider.dart';
/// Order Detail Page /// Order Detail Page
/// ///
@@ -27,9 +30,7 @@ class OrderDetailPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// TODO: Replace with actual order data from provider final orderDetailAsync = ref.watch(orderDetailProvider(orderId));
// For now using mock data based on HTML reference
final mockOrder = _getMockOrder();
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF4F6F8), backgroundColor: const Color(0xFFF4F6F8),
@@ -69,49 +70,28 @@ class OrderDetailPage extends ConsumerWidget {
foregroundColor: AppColors.grey900, foregroundColor: AppColors.grey900,
centerTitle: false, centerTitle: false,
), ),
body: Stack( body: orderDetailAsync.when(
data: (orderDetail) {
return Stack(
children: [ children: [
SingleChildScrollView( SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 100), padding: const EdgeInsets.only(bottom: 100),
child: Column( child: Column(
children: [ children: [
// Status Timeline Card // Status Timeline Card
_buildStatusTimelineCard( _buildStatusTimelineCard(orderDetail),
mockOrder['orderNumber']! as String,
mockOrder['status']! as OrderStatus,
mockOrder['statusHistory']! as List<Map<String, dynamic>>,
),
// Delivery Information Card // Delivery/Address Information Card
_buildDeliveryInfoCard( _buildAddressInfoCard(orderDetail),
mockOrder['deliveryMethod']! as String,
mockOrder['warehouseDate']! as DateTime,
mockOrder['deliveryDate']! as DateTime,
mockOrder['deliveryAddress']! as String,
mockOrder['receiverName']! as String,
mockOrder['receiverPhone']! as String,
),
// Customer Information Card // Customer Information Card
_buildCustomerInfoCard( _buildCustomerInfoCard(orderDetail),
mockOrder['customerName']! as String,
mockOrder['customerPhone']! as String,
mockOrder['customerEmail']! as String,
mockOrder['customerType']! as String,
),
// Products List Card // Products List Card
_buildProductsListCard(), _buildProductsListCard(orderDetail),
// Order Summary Card // Order Summary Card
_buildOrderSummaryCard( _buildOrderSummaryCard(orderDetail),
mockOrder['subtotal']! as double,
mockOrder['shippingFee']! as double,
mockOrder['discount']! as double,
mockOrder['total']! as double,
mockOrder['paymentMethod']! as String,
mockOrder['notes'] as String?,
),
], ],
), ),
), ),
@@ -121,84 +101,67 @@ class OrderDetailPage extends ConsumerWidget {
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
child: Container( child: _buildActionButtons(context, orderDetail),
decoration: BoxDecoration(
color: AppColors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 15,
offset: const Offset(0, -4),
), ),
], ],
), );
padding: const EdgeInsets.all(16), },
child: Row( loading: () => const Center(child: CircularProgressIndicator()),
spacing: 12, error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Expanded( const FaIcon(
child: OutlinedButton.icon( FontAwesomeIcons.circleExclamation,
size: 64,
color: AppColors.danger,
),
const SizedBox(height: 16),
const Text(
'Không thể tải thông tin đơn hàng',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
const SizedBox(height: 8),
Text(
error.toString(),
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () { onPressed: () {
// TODO: Implement contact customer ref.invalidate(orderDetailProvider(orderId));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Gọi điện cho khách hàng...'),
),
);
}, },
icon: const FaIcon(FontAwesomeIcons.phone, size: 18), icon: const FaIcon(FontAwesomeIcons.arrowsRotate, size: 16),
label: const Text('Liên hệ khách hàng'), label: const Text('Thử lại'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: const BorderSide(
color: AppColors.grey100,
width: 2,
),
foregroundColor: AppColors.grey900,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
// TODO: Implement update status
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cập nhật trạng thái...'),
),
);
},
icon: const FaIcon(FontAwesomeIcons.penToSquare, size: 18),
label: const Text('Cập nhật trạng thái'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
backgroundColor: AppColors.primaryBlue, backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white, foregroundColor: Colors.white,
elevation: 0, padding: const EdgeInsets.symmetric(
shape: RoundedRectangleBorder( horizontal: 24,
borderRadius: BorderRadius.circular(8), vertical: 12,
),
),
),
),
],
), ),
), ),
), ),
], ],
), ),
),
),
); );
} }
/// Build Status Timeline Card /// Build Status Timeline Card
Widget _buildStatusTimelineCard( Widget _buildStatusTimelineCard(OrderDetail orderDetail) {
String orderNumber, final order = orderDetail.order;
OrderStatus currentStatus, final timeline = orderDetail.timeline;
List<Map<String, dynamic>> statusHistory,
) {
return Card( return Card(
margin: const EdgeInsets.all(16), margin: const EdgeInsets.all(16),
elevation: 1, elevation: 1,
@@ -209,33 +172,30 @@ class OrderDetailPage extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Order Number and Status Badge // Order Number and Status Badge
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text( Text(
'#$orderNumber', '#${order.name}',
style: const TextStyle( style: const TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.primaryBlue, color: AppColors.primaryBlue,
), ),
), ),
_buildStatusBadge(currentStatus),
], const SizedBox(height: 12,),
), _buildStatusBadge(order.status, order.statusColor),
const SizedBox(height: 24), const SizedBox(height: 24),
// Status Timeline // Status Timeline
...statusHistory.asMap().entries.map((entry) { ...timeline.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
final item = entry.value; final item = entry.value;
final isLast = index == statusHistory.length - 1; final isLast = index == timeline.length - 1;
return _buildTimelineItem( return _buildTimelineItem(
title: item['title']! as String, title: item.label,
date: item['date'] as String?, date: item.value,
status: item['status']! as String, status: item.status,
isLast: isLast, isLast: isLast,
); );
}), }),
@@ -249,25 +209,25 @@ class OrderDetailPage extends ConsumerWidget {
Widget _buildTimelineItem({ Widget _buildTimelineItem({
required String title, required String title,
String? date, String? date,
required String status, // 'completed', 'active', 'pending' required String status, // 'Success', 'Warning', 'Secondary', etc.
required bool isLast, required bool isLast,
}) { }) {
final statusColor = StatusColor.fromString(status) ?? StatusColor.secondary;
Color iconColor; Color iconColor;
Color iconBgColor; Color iconBgColor;
IconData iconData; IconData iconData;
switch (status) { if (statusColor == StatusColor.success) {
case 'completed':
iconColor = Colors.white; iconColor = Colors.white;
iconBgColor = AppColors.success; iconBgColor = statusColor.color;
iconData = FontAwesomeIcons.check; iconData = FontAwesomeIcons.check;
break; } else if (statusColor == StatusColor.warning) {
case 'active':
iconColor = Colors.white; iconColor = Colors.white;
iconBgColor = AppColors.warning; iconBgColor = statusColor.color;
iconData = FontAwesomeIcons.gear; iconData = FontAwesomeIcons.gear;
break; } else {
default: // pending // Secondary or other
iconColor = AppColors.grey500; iconColor = AppColors.grey500;
iconBgColor = AppColors.grey100; iconBgColor = AppColors.grey100;
iconData = _getIconForTitle(title); iconData = _getIconForTitle(title);
@@ -286,7 +246,7 @@ class OrderDetailPage extends ConsumerWidget {
color: iconBgColor, color: iconBgColor,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: FaIcon(iconData, size: 10, color: iconColor), child: Center(child: FaIcon(iconData, size: 10, color: iconColor)),
), ),
if (!isLast) if (!isLast)
Container( Container(
@@ -342,85 +302,34 @@ class OrderDetailPage extends ConsumerWidget {
} }
/// Build Status Badge /// Build Status Badge
Widget _buildStatusBadge(OrderStatus status) { Widget _buildStatusBadge(String status, String statusColorName) {
final statusColor = StatusColor.fromString(statusColorName) ?? StatusColor.secondary;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getStatusColor(status).withValues(alpha: 0.1), color: statusColor.light,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: _getStatusColor(status).withValues(alpha: 0.3), color: statusColor.border,
width: 1, width: 1,
), ),
), ),
child: Text( child: Text(
_getStatusText(status), status,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: _getStatusColor(status), color: statusColor.color,
), ),
), ),
); );
} }
/// Get status color /// Build Address Info Card
Color _getStatusColor(OrderStatus status) { Widget _buildAddressInfoCard(OrderDetail orderDetail) {
switch (status) { final order = orderDetail.order;
case OrderStatus.draft: final shippingAddress = orderDetail.shippingAddress;
return AppColors.grey500;
case OrderStatus.pending:
return AppColors.info;
case OrderStatus.confirmed:
return AppColors.info;
case OrderStatus.processing:
return AppColors.warning;
case OrderStatus.shipped:
return AppColors.primaryBlue;
case OrderStatus.delivered:
return AppColors.success;
case OrderStatus.completed:
return AppColors.success;
case OrderStatus.cancelled:
return AppColors.danger;
case OrderStatus.refunded:
return const Color(0xFFF97316);
}
}
/// Get status text
String _getStatusText(OrderStatus status) {
switch (status) {
case OrderStatus.draft:
return 'Nháp';
case OrderStatus.pending:
return 'Chờ xác nhận';
case OrderStatus.confirmed:
return 'Đã xác nhận';
case OrderStatus.processing:
return 'Đang xử lý';
case OrderStatus.shipped:
return 'Đang vận chuyển';
case OrderStatus.delivered:
return 'Đã giao hàng';
case OrderStatus.completed:
return 'Hoàn thành';
case OrderStatus.cancelled:
return 'Đã hủy';
case OrderStatus.refunded:
return 'Đã hoàn tiền';
}
}
/// Build Delivery Info Card
Widget _buildDeliveryInfoCard(
String deliveryMethod,
DateTime warehouseDate,
DateTime deliveryDate,
String deliveryAddress,
String receiverName,
String receiverPhone,
) {
final dateFormatter = DateFormat('dd/MM/yyyy'); final dateFormatter = DateFormat('dd/MM/yyyy');
return Card( return Card(
@@ -453,72 +362,11 @@ class OrderDetailPage extends ConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
// Delivery Method // Delivery Date
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.grey50,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.primaryBlue,
borderRadius: BorderRadius.circular(8),
),
child: const FaIcon(
FontAwesomeIcons.truck,
color: Colors.white,
size: 18,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
deliveryMethod,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
const SizedBox(height: 2),
const Text(
'Giao trong 3-5 ngày làm việc',
style: TextStyle(
fontSize: 12,
color: AppColors.grey500,
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
// Delivery Details
_buildInfoRow( _buildInfoRow(
icon: FontAwesomeIcons.calendar, icon: FontAwesomeIcons.calendar,
label: 'Ngày xuất kho', label: 'Ngày giao hàng',
value: dateFormatter.format(warehouseDate), value: dateFormatter.format(DateTime.parse(order.deliveryDate)),
valueColor: AppColors.success,
),
const SizedBox(height: 12),
_buildInfoRow(
icon: FontAwesomeIcons.clock,
label: 'Thời gian giao hàng',
value: '${dateFormatter.format(deliveryDate)}, 8:00 - 17:00',
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -526,7 +374,8 @@ class OrderDetailPage extends ConsumerWidget {
_buildInfoRow( _buildInfoRow(
icon: FontAwesomeIcons.locationDot, icon: FontAwesomeIcons.locationDot,
label: 'Địa chỉ giao hàng', label: 'Địa chỉ giao hàng',
value: deliveryAddress, value:
'${shippingAddress.addressLine1}\n${shippingAddress.wardName}, ${shippingAddress.cityName}',
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -534,7 +383,7 @@ class OrderDetailPage extends ConsumerWidget {
_buildInfoRow( _buildInfoRow(
icon: FontAwesomeIcons.user, icon: FontAwesomeIcons.user,
label: 'Người nhận', label: 'Người nhận',
value: '$receiverName - $receiverPhone', value: '${shippingAddress.addressTitle} - ${shippingAddress.phone}',
), ),
], ],
), ),
@@ -590,12 +439,9 @@ class OrderDetailPage extends ConsumerWidget {
} }
/// Build Customer Info Card /// Build Customer Info Card
Widget _buildCustomerInfoCard( Widget _buildCustomerInfoCard(OrderDetail orderDetail) {
String customerName, final order = orderDetail.order;
String customerPhone, final billingAddress = orderDetail.billingAddress;
String customerEmail,
String customerType,
) {
return Card( return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 1, elevation: 1,
@@ -626,46 +472,19 @@ class OrderDetailPage extends ConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
_buildCustomerRow('Tên khách hàng:', customerName), _buildCustomerRow('Tên khách hàng:', order.customer),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildCustomerRow('Số điện thoại:', customerPhone), _buildCustomerRow('Số điện thoại:', billingAddress.phone),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildCustomerRow('Email:', customerEmail), _buildCustomerRow('Email:', billingAddress.email),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( if (billingAddress.taxCode.isNotEmpty) ...[
mainAxisAlignment: MainAxisAlignment.spaceBetween, _buildCustomerRow('Mã số thuế:', billingAddress.taxCode),
children: [ const SizedBox(height: 12),
const Text(
'Loại khách hàng:',
style: TextStyle(fontSize: 14, color: AppColors.grey500),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFFFD700), Color(0xFFFFA500)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
child: Text(
customerType,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
], ],
),
], ],
), ),
), ),
@@ -694,25 +513,13 @@ class OrderDetailPage extends ConsumerWidget {
} }
/// Build Products List Card /// Build Products List Card
Widget _buildProductsListCard() { Widget _buildProductsListCard(OrderDetail orderDetail) {
final products = [ final items = orderDetail.items;
{ final currencyFormatter = NumberFormat.currency(
'name': 'Gạch Eurotile MỘC LAM E03', locale: 'vi_VN',
'size': '60x60cm', symbol: 'đ',
'sku': 'ET-ML-E03-60x60', decimalDigits: 0,
'quantity': '30 m²', );
'unitPrice': '285.000đ/m²',
'totalPrice': '8.550.000đ',
},
{
'name': 'Gạch Eurotile STONE GREY S02',
'size': '80x80cm',
'sku': 'ET-SG-S02-80x80',
'quantity': '20 m²',
'unitPrice': '217.500đ/m²',
'totalPrice': '4.350.000đ',
},
];
return Card( return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -744,8 +551,8 @@ class OrderDetailPage extends ConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
...products.map( ...items.map(
(product) => Container( (item) => Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -756,6 +563,35 @@ class OrderDetailPage extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Product Image // Product Image
if (item.thumbnail != null)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: CachedNetworkImage(
imageUrl: item.thumbnail!,
width: 60,
height: 60,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 60,
height: 60,
color: AppColors.grey50,
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
),
errorWidget: (context, url, error) => Container(
width: 60,
height: 60,
color: AppColors.grey50,
child: const FaIcon(
FontAwesomeIcons.image,
color: AppColors.grey500,
size: 28,
),
),
),
)
else
Container( Container(
width: 60, width: 60,
height: 60, height: 60,
@@ -778,7 +614,7 @@ class OrderDetailPage extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
product['name']!, item.itemName,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -787,14 +623,7 @@ class OrderDetailPage extends ConsumerWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Kích thước: ${product['size']}', 'Mã: ${item.itemCode}',
style: const TextStyle(
fontSize: 12,
color: AppColors.grey500,
),
),
Text(
'SKU: ${product['sku']}',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.grey500, color: AppColors.grey500,
@@ -815,7 +644,7 @@ class OrderDetailPage extends ConsumerWidget {
), ),
), ),
Text( Text(
product['quantity']!, '${item.qtyOfSm}',
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -828,14 +657,14 @@ class OrderDetailPage extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
product['unitPrice']!, '${currencyFormatter.format(item.price)}/m²',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.grey500, color: AppColors.grey500,
), ),
), ),
Text( Text(
product['totalPrice']!, currencyFormatter.format(item.totalAmount),
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -860,14 +689,9 @@ class OrderDetailPage extends ConsumerWidget {
} }
/// Build Order Summary Card /// Build Order Summary Card
Widget _buildOrderSummaryCard( Widget _buildOrderSummaryCard(OrderDetail orderDetail) {
double subtotal, final order = orderDetail.order;
double shippingFee, final paymentTerms = orderDetail.paymentTerms;
double discount,
double total,
String paymentMethod,
String? notes,
) {
final currencyFormatter = NumberFormat.currency( final currencyFormatter = NumberFormat.currency(
locale: 'vi_VN', locale: 'vi_VN',
symbol: 'đ', symbol: 'đ',
@@ -904,35 +728,29 @@ class OrderDetailPage extends ConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
_buildSummaryRow('Tạm tính:', currencyFormatter.format(subtotal)), _buildSummaryRow('Tổng tiền hàng:', currencyFormatter.format(order.total)),
const SizedBox(height: 8), const SizedBox(height: 8),
if (order.totalRemaining > 0) ...[
_buildSummaryRow( _buildSummaryRow(
'Phí vận chuyển:', 'Còn lại:',
shippingFee == 0 currencyFormatter.format(order.totalRemaining),
? 'Miễn phí' valueColor: AppColors.warning,
: currencyFormatter.format(shippingFee),
valueColor: shippingFee == 0 ? AppColors.success : null,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
],
_buildSummaryRow(
'Giảm giá VIP:',
'-${currencyFormatter.format(discount)}',
valueColor: AppColors.success,
),
const Divider(height: 24), const Divider(height: 24),
_buildSummaryRow( _buildSummaryRow(
'Tổng cộng:', 'Tổng cộng:',
currencyFormatter.format(total), currencyFormatter.format(order.grandTotal),
isTotal: true, isTotal: true,
), ),
const Divider(height: 24), const Divider(height: 24),
// Payment Method // Payment Terms
Row( Row(
children: [ children: [
const FaIcon( const FaIcon(
@@ -942,22 +760,31 @@ class OrderDetailPage extends ConsumerWidget {
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
const Text( const Text(
'Phương thức thanh toán:', 'Điều khoản thanh toán:',
style: TextStyle(fontSize: 14, color: AppColors.grey500), style: TextStyle(fontSize: 14, color: AppColors.grey500),
), ),
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
paymentMethod, paymentTerms.name,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w600,
color: AppColors.grey900, color: AppColors.grey900,
), ),
), ),
const SizedBox(height: 4),
Text(
paymentTerms.description,
style: const TextStyle(
fontSize: 12,
color: AppColors.grey500,
height: 1.4,
),
),
if (notes != null) ...[ if (order.description.isNotEmpty) ...[
const Divider(height: 24), const Divider(height: 24),
// Order Notes // Order Notes
@@ -973,7 +800,7 @@ class OrderDetailPage extends ConsumerWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
notes, order.description,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -1019,56 +846,73 @@ class OrderDetailPage extends ConsumerWidget {
); );
} }
/// Get mock order data for development /// Build Action Buttons
Map<String, dynamic> _getMockOrder() { Widget _buildActionButtons(BuildContext context, OrderDetail orderDetail) {
return { final shippingAddress = orderDetail.shippingAddress;
'orderNumber': 'DH001234',
'status': OrderStatus.processing, return Container(
'statusHistory': [ decoration: BoxDecoration(
{ color: AppColors.white,
'title': 'Đơn hàng được tạo', boxShadow: [
'date': '03/08/2023 - 09:30', BoxShadow(
'status': 'completed', color: Colors.black.withValues(alpha: 0.1),
}, blurRadius: 15,
{ offset: const Offset(0, -4),
'title': 'Đã xác nhận đơn hàng', ),
'date': '03/08/2023 - 10:15',
'status': 'completed',
},
{
'title': 'Đang chuẩn bị hàng',
'date': 'Đang thực hiện',
'status': 'active',
},
{
'title': 'Vận chuyển',
'date': 'Dự kiến: 05/08/2023',
'status': 'pending',
},
{
'title': 'Giao hàng thành công',
'date': 'Dự kiến: 07/08/2023',
'status': 'pending',
},
], ],
'deliveryMethod': 'Giao hàng tiêu chuẩn', ),
'warehouseDate': DateTime(2023, 8, 5), padding: const EdgeInsets.all(16),
'deliveryDate': DateTime(2023, 8, 7), child: Row(
'deliveryAddress': spacing: 12,
'123 Đường Lê Văn Lương, Phường Tân Hưng,\nQuận 7, TP. Hồ Chí Minh', children: [
'receiverName': 'Nguyễn Văn A', Expanded(
'receiverPhone': '0901234567', child: OutlinedButton.icon(
'customerName': 'Nguyễn Văn A', onPressed: () {
'customerPhone': '0901234567', ScaffoldMessenger.of(context).showSnackBar(
'customerEmail': 'nguyenvana@email.com', SnackBar(
'customerType': 'Khách VIP', content: Text('Gọi ${shippingAddress.phone}...'),
'subtotal': 12900000.0, ),
'shippingFee': 0.0, );
'discount': 129000.0, },
'total': 12771000.0, icon: const FaIcon(FontAwesomeIcons.phone, size: 18),
'paymentMethod': 'Chuyển khoản ngân hàng', label: const Text('Liên hệ'),
'notes': style: OutlinedButton.styleFrom(
'Giao hàng trong giờ hành chính. Vui lòng gọi trước 30 phút khi đến giao hàng.', padding: const EdgeInsets.symmetric(vertical: 12),
}; side: const BorderSide(
color: AppColors.grey100,
width: 2,
),
foregroundColor: AppColors.grey900,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Chức năng đang phát triển...'),
),
);
},
icon: const FaIcon(FontAwesomeIcons.penToSquare, size: 18),
label: const Text('Cập nhật'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
);
} }
} }

View File

@@ -8,7 +8,6 @@ import 'package:flutter_riverpod/flutter_riverpod.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:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/database/models/enums.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/orders/presentation/providers/orders_provider.dart'; import 'package:worker/features/orders/presentation/providers/orders_provider.dart';
import 'package:worker/features/orders/presentation/widgets/order_card.dart'; import 'package:worker/features/orders/presentation/widgets/order_card.dart';
@@ -77,16 +76,28 @@ class _OrdersPageState extends ConsumerState<OrdersPage> {
}, },
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
// Search Bar // Sticky Search Bar
SliverToBoxAdapter( SliverPersistentHeader(
child: Padding( pinned: true,
delegate: _SearchBarDelegate(
child: Container(
color: const Color(0xFFF4F6F8),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: _buildSearchBar(), child: _buildSearchBar(),
), ),
), ),
),
// Filter Pills // Sticky Filter Pills
SliverToBoxAdapter(child: _buildFilterPills(selectedStatus)), SliverPersistentHeader(
pinned: true,
delegate: _FilterPillsDelegate(
child: Container(
color: const Color(0xFFF4F6F8),
child: _buildFilterPills(selectedStatus),
),
),
),
// Orders List // Orders List
SliverPadding( SliverPadding(
@@ -103,7 +114,7 @@ class _OrdersPageState extends ConsumerState<OrdersPage> {
return OrderCard( return OrderCard(
order: order, order: order,
onTap: () { onTap: () {
context.push('/orders/${order.orderId}'); context.push('/orders/${order.name}');
}, },
); );
}, childCount: orders.length), }, childCount: orders.length),
@@ -168,15 +179,19 @@ class _OrdersPageState extends ConsumerState<OrdersPage> {
); );
} }
/// Build filter pills /// Build filter pills (dynamically from cached status list)
Widget _buildFilterPills(OrderStatus? selectedStatus) { Widget _buildFilterPills(String? selectedStatus) {
return Container( final statusListAsync = ref.watch(orderStatusListProvider);
return SizedBox(
height: 48, height: 48,
child: statusListAsync.when(
data: (statusList) {
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
children: [ children: [
// All filter // All filter (always first)
_buildFilterChip( _buildFilterChip(
label: 'Tất cả', label: 'Tất cả',
isSelected: selectedStatus == null, isSelected: selectedStatus == null,
@@ -186,65 +201,52 @@ class _OrdersPageState extends ConsumerState<OrdersPage> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
// Pending filter // Dynamic status filters from API
_buildFilterChip( ...statusList.map((status) {
label: 'Chờ xác nhận', return Padding(
isSelected: selectedStatus == OrderStatus.pending, padding: const EdgeInsets.only(right: 8),
child: _buildFilterChip(
label: status.label,
isSelected: selectedStatus == status.label,
onTap: () { onTap: () {
ref ref
.read(selectedOrderStatusProvider.notifier) .read(selectedOrderStatusProvider.notifier)
.selectStatus(OrderStatus.pending); .selectStatus(status.label);
}, },
), ),
const SizedBox(width: 8), );
}),
// Processing filter ],
_buildFilterChip( );
label: 'Đang xử lý',
isSelected: selectedStatus == OrderStatus.processing,
onTap: () {
ref
.read(selectedOrderStatusProvider.notifier)
.selectStatus(OrderStatus.processing);
}, },
), loading: () {
const SizedBox(width: 8), // Show minimal loading state or fallback to "All" only
return ListView(
// Shipped filter padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
children: [
_buildFilterChip( _buildFilterChip(
label: 'Đang giao', label: 'Tất cả',
isSelected: selectedStatus == OrderStatus.shipped, isSelected: true,
onTap: () { onTap: () {},
ref
.read(selectedOrderStatusProvider.notifier)
.selectStatus(OrderStatus.shipped);
},
),
const SizedBox(width: 8),
// Completed filter
_buildFilterChip(
label: 'Hoàn thành',
isSelected: selectedStatus == OrderStatus.completed,
onTap: () {
ref
.read(selectedOrderStatusProvider.notifier)
.selectStatus(OrderStatus.completed);
},
),
const SizedBox(width: 8),
// Cancelled filter
_buildFilterChip(
label: 'Đã hủy',
isSelected: selectedStatus == OrderStatus.cancelled,
onTap: () {
ref
.read(selectedOrderStatusProvider.notifier)
.selectStatus(OrderStatus.cancelled);
},
), ),
], ],
);
},
error: (error, stack) {
// Show "All" filter only on error
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
children: [
_buildFilterChip(
label: 'Tất cả',
isSelected: true,
onTap: () {},
),
],
);
},
), ),
); );
} }
@@ -349,3 +351,57 @@ class _OrdersPageState extends ConsumerState<OrdersPage> {
); );
} }
} }
/// Search Bar Delegate for SliverPersistentHeader
class _SearchBarDelegate extends SliverPersistentHeaderDelegate {
_SearchBarDelegate({required this.child});
final Widget child;
@override
double get minExtent => 80; // Height when pinned
@override
double get maxExtent => 80; // Height when expanded
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return child;
}
@override
bool shouldRebuild(_SearchBarDelegate oldDelegate) {
return child != oldDelegate.child;
}
}
/// Filter Pills Delegate for SliverPersistentHeader
class _FilterPillsDelegate extends SliverPersistentHeaderDelegate {
_FilterPillsDelegate({required this.child});
final Widget child;
@override
double get minExtent => 48; // Height when pinned (matches Container height)
@override
double get maxExtent => 48; // Height when expanded (matches Container height)
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return child;
}
@override
bool shouldRebuild(_FilterPillsDelegate oldDelegate) {
return child != oldDelegate.child;
}
}

View File

@@ -11,15 +11,18 @@
library; library;
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package: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:image_picker/image_picker.dart';
import 'package:qr_flutter/qr_flutter.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/router/app_router.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'; import 'package:worker/features/orders/presentation/providers/order_repository_provider.dart';
@@ -47,6 +50,10 @@ class PaymentQrPage extends HookConsumerWidget {
final remainingSeconds = useState<int>(900); final remainingSeconds = useState<int>(900);
final timer = useRef<Timer?>(null); final timer = useRef<Timer?>(null);
// Upload state
final isUploadingBill = useState<bool>(false);
final selectedImagePath = useState<String?>(null);
// Fetch QR code data // Fetch QR code data
useEffect(() { useEffect(() {
Future<void> fetchQrCode() async { Future<void> fetchQrCode() async {
@@ -146,8 +153,27 @@ class PaymentQrPage extends HookConsumerWidget {
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
// Image Preview Section
_buildImagePreviewSection(
context,
selectedImagePath.value,
() async {
await _selectImage(context, selectedImagePath);
},
),
const SizedBox(height: AppSpacing.md),
// Action Buttons // Action Buttons
_buildActionButtons(context), _buildActionButtons(
context,
ref,
isUploadingBill.value,
selectedImagePath.value != null,
() async {
await _uploadBill(context, ref, selectedImagePath, isUploadingBill);
},
),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
@@ -452,44 +478,174 @@ class PaymentQrPage extends HookConsumerWidget {
); );
} }
/// Build image preview section
Widget _buildImagePreviewSection(
BuildContext context,
String? imagePath,
VoidCallback onSelectImage,
) {
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Ảnh hóa đơn',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
),
),
const SizedBox(height: AppSpacing.md),
// Image preview or placeholder
InkWell(
onTap: onSelectImage,
borderRadius: BorderRadius.circular(AppRadius.card),
child: Container(
width: double.infinity,
height: 200,
decoration: BoxDecoration(
color: const Color(0xFFF4F6F8),
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(
color: const Color(0xFFE2E8F0),
width: 2,
style: BorderStyle.solid,
),
),
child: imagePath != null
? ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.card - 2),
child: Stack(
children: [
Image.file(
File(imagePath),
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
Positioned(
top: 8,
right: 8,
child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
FaIcon(
FontAwesomeIcons.pen,
color: Colors.white,
size: 12,
),
SizedBox(width: 6),
Text(
'Đổi ảnh',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: AppColors.primaryBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(30),
),
child: const Icon(
FontAwesomeIcons.image,
color: AppColors.primaryBlue,
size: 24,
),
),
const SizedBox(height: 12),
const Text(
'Chạm để chọn ảnh hóa đơn',
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
const Text(
'Hỗ trợ: JPG, PNG',
style: TextStyle(
fontSize: 12,
color: AppColors.grey500,
),
),
],
),
),
),
],
),
);
}
/// Build action buttons /// Build action buttons
Widget _buildActionButtons(BuildContext context) { Widget _buildActionButtons(
BuildContext context,
WidgetRef ref,
bool isUploading,
bool hasImage,
VoidCallback onUpload,
) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Row( child: Column(
children: [ children: [
// Confirmed Payment Button // Upload Bill Button
Expanded( SizedBox(
child: OutlinedButton.icon( width: double.infinity,
onPressed: () => _confirmPayment(context),
icon: const FaIcon(FontAwesomeIcons.check, size: 18),
label: const Text(
'Đã thanh toán',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryBlue,
side: const BorderSide(
color: AppColors.primaryBlue,
width: 1.5,
),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
),
),
),
),
const SizedBox(width: AppSpacing.sm),
// Upload Proof Button
Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () => _uploadProof(context), onPressed: (isUploading || !hasImage) ? null : onUpload,
icon: const FaIcon(FontAwesomeIcons.camera, size: 18), icon: isUploading
label: const Text( ? const SizedBox(
'Upload bill', width: 18,
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const FaIcon(FontAwesomeIcons.camera, size: 18),
label: Text(
isUploading ? 'Đang upload...' : 'Upload bill chuyển khoản',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue, backgroundColor: AppColors.primaryBlue,
@@ -499,6 +655,33 @@ class PaymentQrPage extends HookConsumerWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button), borderRadius: BorderRadius.circular(AppRadius.button),
), ),
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
),
),
),
const SizedBox(height: AppSpacing.sm),
// Back to Home Button
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: isUploading ? null : () => context.goNamed(RouteNames.home),
icon: const FaIcon(FontAwesomeIcons.house, size: 18),
label: const Text(
'Quay về trang chủ',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.grey900,
side: const BorderSide(
color: AppColors.grey100,
width: 1.5,
),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
),
), ),
), ),
), ),
@@ -598,57 +781,209 @@ class PaymentQrPage extends HookConsumerWidget {
); );
} }
/// Confirm payment /// Select image for bill
void _confirmPayment(BuildContext context) { Future<void> _selectImage(
showDialog<void>( BuildContext context,
ValueNotifier<String?> selectedImagePath,
) async {
// Show bottom sheet to select camera or gallery
final ImageSource? source = await showModalBottomSheet<ImageSource>(
context: context, context: context,
builder: (context) => AlertDialog( shape: const RoundedRectangleBorder(
title: const Text('Xác nhận thanh toán'), borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.card)),
content: const Text(
'Bạn đã hoàn tất thanh toán cho đơn hàng này?',
style: TextStyle(fontSize: 14),
), ),
actions: [ builder: (context) => Container(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Chọn ảnh hóa đơn',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
),
),
const SizedBox(height: AppSpacing.md),
ListTile(
leading: const FaIcon(
FontAwesomeIcons.camera,
color: AppColors.primaryBlue,
),
title: const Text('Chụp ảnh'),
onTap: () => Navigator.of(context).pop(ImageSource.camera),
),
ListTile(
leading: const FaIcon(
FontAwesomeIcons.image,
color: AppColors.primaryBlue,
),
title: const Text('Chọn từ thư viện'),
onTap: () => Navigator.of(context).pop(ImageSource.gallery),
),
const SizedBox(height: AppSpacing.sm),
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
child: const Text('Chưa'), child: const Text('Hủy'),
), ),
ElevatedButton( ],
onPressed: () { ),
Navigator.of(context).pop(); ),
);
if (source == null || !context.mounted) return;
try {
// Pick image
final picker = ImagePicker();
final pickedFile = await picker.pickImage(
source: source,
maxWidth: 1920,
maxHeight: 1080,
imageQuality: 85,
);
if (pickedFile == null || !context.mounted) return;
selectedImagePath.value = pickedFile.path;
// Show success feedback
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Đã xác nhận thanh toán!'), content: Row(
children: [
FaIcon(FontAwesomeIcons.circleCheck, color: Colors.white, size: 20),
SizedBox(width: 12),
Text('Đã chọn ảnh. Nhấn "Upload bill chuyển khoản" để gửi.'),
],
),
backgroundColor: AppColors.success, backgroundColor: AppColors.success,
duration: Duration(seconds: 2), duration: Duration(seconds: 2),
), ),
); );
// Navigate back after delay } catch (e) {
Future.delayed(const Duration(milliseconds: 500), () { if (!context.mounted) return;
if (context.mounted) {
context.pop(); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi khi chọn ảnh: ${e.toString()}'),
backgroundColor: AppColors.danger,
duration: const Duration(seconds: 2),
),
);
} }
}); }
},
/// Upload bill to server
Future<void> _uploadBill(
BuildContext context,
WidgetRef ref,
ValueNotifier<String?> selectedImagePath,
ValueNotifier<bool> isUploadingBill,
) async {
if (selectedImagePath.value == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vui lòng chọn ảnh hóa đơn trước'),
backgroundColor: AppColors.danger,
duration: Duration(seconds: 2),
),
);
return;
}
// Show confirmation dialog
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Xác nhận upload'),
content: const Text(
'Bạn có muốn upload hóa đơn này không?',
style: TextStyle(fontSize: 14),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Hủy'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue, backgroundColor: AppColors.primaryBlue,
), ),
child: const Text('Đã thanh toán'), child: const Text('Upload'),
), ),
], ],
), ),
); );
}
/// Upload payment proof if (confirmed != true || !context.mounted) return;
void _uploadProof(BuildContext context) {
// TODO: Implement image picker and upload try {
// Start upload
isUploadingBill.value = true;
final repository = await ref.read(orderRepositoryProvider.future);
final result = await repository.uploadBill(
filePath: selectedImagePath.value!,
orderId: orderId,
);
if (!context.mounted) return;
isUploadingBill.value = false;
// Show success message and navigate
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Tính năng upload bill đang được phát triển'), content: Row(
children: [
FaIcon(FontAwesomeIcons.circleCheck, color: Colors.white, size: 20),
SizedBox(width: 12),
Text('Upload hóa đơn thành công!'),
],
),
backgroundColor: AppColors.success,
duration: Duration(seconds: 2), duration: Duration(seconds: 2),
), ),
); );
// Navigate to order success page after successful upload
Future.delayed(const Duration(milliseconds: 500), () {
if (context.mounted) {
context.pushReplacementNamed(
RouteNames.orderSuccess,
queryParameters: {
'orderNumber': orderId,
'total': amount.toString(),
'isNegotiation': 'false',
},
);
}
});
} catch (e) {
if (!context.mounted) return;
isUploadingBill.value = false;
// Show error message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const FaIcon(FontAwesomeIcons.circleXmark, color: Colors.white, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text('Lỗi upload: ${e.toString()}'),
),
],
),
backgroundColor: AppColors.danger,
duration: const Duration(seconds: 3),
),
);
}
} }
/// Format currency /// Format currency

View File

@@ -6,6 +6,7 @@ library;
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/network/dio_client.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/datasources/order_remote_datasource.dart';
import 'package:worker/features/orders/data/datasources/order_status_local_datasource.dart';
import 'package:worker/features/orders/data/repositories/order_repository_impl.dart'; import 'package:worker/features/orders/data/repositories/order_repository_impl.dart';
import 'package:worker/features/orders/domain/repositories/order_repository.dart'; import 'package:worker/features/orders/domain/repositories/order_repository.dart';
@@ -22,5 +23,6 @@ Future<OrderRemoteDataSource> orderRemoteDataSource(Ref ref) async {
@riverpod @riverpod
Future<OrderRepository> orderRepository(Ref ref) async { Future<OrderRepository> orderRepository(Ref ref) async {
final remoteDataSource = await ref.watch(orderRemoteDataSourceProvider.future); final remoteDataSource = await ref.watch(orderRemoteDataSourceProvider.future);
return OrderRepositoryImpl(remoteDataSource); final statusLocalDataSource = OrderStatusLocalDataSource();
return OrderRepositoryImpl(remoteDataSource, statusLocalDataSource);
} }

View File

@@ -97,4 +97,4 @@ final class OrderRepositoryProvider
} }
} }
String _$orderRepositoryHash() => r'985408a6667ab31427524f9b1981287c28f4f221'; String _$orderRepositoryHash() => r'd1b811cb1849e44c48ce02d7bb620de1b0ccdfb8';

View File

@@ -6,6 +6,7 @@ library;
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/network/dio_client.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/datasources/order_remote_datasource.dart';
import 'package:worker/features/orders/data/datasources/order_status_local_datasource.dart';
import 'package:worker/features/orders/data/repositories/order_repository_impl.dart'; import 'package:worker/features/orders/data/repositories/order_repository_impl.dart';
import 'package:worker/features/orders/domain/repositories/order_repository.dart'; import 'package:worker/features/orders/domain/repositories/order_repository.dart';
@@ -16,7 +17,8 @@ part 'order_repository_provider.g.dart';
Future<OrderRepository> orderRepository(Ref ref) async { Future<OrderRepository> orderRepository(Ref ref) async {
final dioClient = await ref.watch(dioClientProvider.future); final dioClient = await ref.watch(dioClientProvider.future);
final remoteDataSource = OrderRemoteDataSource(dioClient); final remoteDataSource = OrderRemoteDataSource(dioClient);
return OrderRepositoryImpl(remoteDataSource); final statusLocalDataSource = OrderStatusLocalDataSource();
return OrderRepositoryImpl(remoteDataSource, statusLocalDataSource);
} }
/// Create Order Provider /// Create Order Provider

View File

@@ -50,7 +50,7 @@ final class OrderRepositoryProvider
} }
} }
String _$orderRepositoryHash() => r'15efafcf3b545ea52fdc8d0acbd8192ba8f41546'; String _$orderRepositoryHash() => r'f9808aac43686973737a55410e4121ae8332b908';
/// Create Order Provider /// Create Order Provider
/// ///

View File

@@ -4,33 +4,42 @@
library; library;
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/database/models/enums.dart'; import 'package:worker/features/orders/domain/entities/order.dart';
import 'package:worker/features/orders/data/datasources/orders_local_datasource.dart'; import 'package:worker/features/orders/domain/entities/order_detail.dart';
import 'package:worker/features/orders/data/models/order_model.dart'; import 'package:worker/features/orders/domain/entities/order_status.dart';
import 'package:worker/features/orders/presentation/providers/order_repository_provider.dart';
part 'orders_provider.g.dart'; part 'orders_provider.g.dart';
/// Orders Local Data Source Provider
@riverpod
OrdersLocalDataSource ordersLocalDataSource(Ref ref) {
return OrdersLocalDataSource();
}
/// Orders Provider /// Orders Provider
/// ///
/// Provides list of all orders from local data source. /// Provides list of all orders from repository (Clean Architecture).
@riverpod @riverpod
class Orders extends _$Orders { class Orders extends _$Orders {
@override @override
Future<List<OrderModel>> build() async { Future<List<Order>> build() async {
return await ref.read(ordersLocalDataSourceProvider).getAllOrders(); // Fetch orders from repository
try {
final repository = await ref.read(orderRepositoryProvider.future);
return await repository.getOrdersList(
limitStart: 0,
limitPageLength: 0, // 0 = get all
);
} catch (e) {
// Return empty list on error
return [];
}
} }
/// Refresh orders /// Refresh orders
Future<void> refresh() async { Future<void> refresh() async {
state = const AsyncValue.loading(); state = const AsyncValue.loading();
state = await AsyncValue.guard(() async { state = await AsyncValue.guard(() async {
return await ref.read(ordersLocalDataSourceProvider).getAllOrders(); final repository = await ref.read(orderRepositoryProvider.future);
return await repository.getOrdersList(
limitStart: 0,
limitPageLength: 0,
);
}); });
} }
} }
@@ -42,12 +51,12 @@ class Orders extends _$Orders {
@riverpod @riverpod
class SelectedOrderStatus extends _$SelectedOrderStatus { class SelectedOrderStatus extends _$SelectedOrderStatus {
@override @override
OrderStatus? build() { String? build() {
return null; // Default: show all orders return null; // Default: show all orders
} }
/// Select a status filter /// Select a status filter
void selectStatus(OrderStatus? status) { void selectStatus(String? status) {
state = status; state = status;
} }
@@ -82,7 +91,7 @@ class OrderSearchQuery extends _$OrderSearchQuery {
/// ///
/// Filters orders by selected status and search query. /// Filters orders by selected status and search query.
@riverpod @riverpod
Future<List<OrderModel>> filteredOrders(Ref ref) async { Future<List<Order>> filteredOrders(Ref ref) async {
final ordersAsync = ref.watch(ordersProvider); final ordersAsync = ref.watch(ordersProvider);
final selectedStatus = ref.watch(selectedOrderStatusProvider); final selectedStatus = ref.watch(selectedOrderStatusProvider);
final searchQuery = ref.watch(orderSearchQueryProvider); final searchQuery = ref.watch(orderSearchQueryProvider);
@@ -102,15 +111,23 @@ Future<List<OrderModel>> filteredOrders(Ref ref) async {
if (searchQuery.isNotEmpty) { if (searchQuery.isNotEmpty) {
filtered = filtered filtered = filtered
.where( .where(
(order) => order.orderNumber.toLowerCase().contains( (order) => order.name.toLowerCase().contains(
searchQuery.toLowerCase(), searchQuery.toLowerCase(),
), ),
) )
.toList(); .toList();
} }
// Sort by creation date (newest first) // Sort by transaction date (newest first)
filtered.sort((a, b) => b.createdAt.compareTo(a.createdAt)); filtered.sort((a, b) {
try {
final aDate = DateTime.parse(a.transactionDate);
final bDate = DateTime.parse(b.transactionDate);
return bDate.compareTo(aDate);
} catch (e) {
return 0; // Keep original order if parsing fails
}
});
return filtered; return filtered;
}, },
@@ -123,15 +140,16 @@ Future<List<OrderModel>> filteredOrders(Ref ref) async {
/// ///
/// Returns count of orders for each status. /// Returns count of orders for each status.
@riverpod @riverpod
Future<Map<OrderStatus, int>> ordersCountByStatus(Ref ref) async { Future<Map<String, int>> ordersCountByStatus(Ref ref) async {
final ordersAsync = ref.watch(ordersProvider); final ordersAsync = ref.watch(ordersProvider);
return ordersAsync.when( return ordersAsync.when(
data: (orders) { data: (orders) {
final counts = <OrderStatus, int>{}; final counts = <String, int>{};
for (final status in OrderStatus.values) { // Count orders by their status string
counts[status] = orders.where((order) => order.status == status).length; for (final order in orders) {
counts[order.status] = (counts[order.status] ?? 0) + 1;
} }
return counts; return counts;
@@ -152,3 +170,22 @@ Future<int> totalOrdersCount(Ref ref) async {
error: (error, stack) => 0, error: (error, stack) => 0,
); );
} }
/// Order Status List Provider
///
/// Provides cached order status list with automatic refresh.
/// Uses cache-first strategy with API fallback.
@riverpod
Future<List<OrderStatus>> orderStatusList(Ref ref) async {
final repository = await ref.watch(orderRepositoryProvider.future);
return await repository.getOrderStatusList();
}
/// Order Detail Provider
///
/// Provides detailed order information by order ID.
@riverpod
Future<OrderDetail> orderDetail(Ref ref, String orderId) async {
final repository = await ref.watch(orderRepositoryProvider.future);
return await repository.getOrderDetail(orderId);
}

View File

@@ -8,74 +8,20 @@ part of 'orders_provider.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning // ignore_for_file: type=lint, type=warning
/// Orders Local Data Source Provider
@ProviderFor(ordersLocalDataSource)
const ordersLocalDataSourceProvider = OrdersLocalDataSourceProvider._();
/// Orders Local Data Source Provider
final class OrdersLocalDataSourceProvider
extends
$FunctionalProvider<
OrdersLocalDataSource,
OrdersLocalDataSource,
OrdersLocalDataSource
>
with $Provider<OrdersLocalDataSource> {
/// Orders Local Data Source Provider
const OrdersLocalDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'ordersLocalDataSourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$ordersLocalDataSourceHash();
@$internal
@override
$ProviderElement<OrdersLocalDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
OrdersLocalDataSource create(Ref ref) {
return ordersLocalDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(OrdersLocalDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<OrdersLocalDataSource>(value),
);
}
}
String _$ordersLocalDataSourceHash() =>
r'753fcc2a4000c4c9843fba022d1bf398daba6c7a';
/// Orders Provider /// Orders Provider
/// ///
/// Provides list of all orders from local data source. /// Provides list of all orders from repository (Clean Architecture).
@ProviderFor(Orders) @ProviderFor(Orders)
const ordersProvider = OrdersProvider._(); const ordersProvider = OrdersProvider._();
/// Orders Provider /// Orders Provider
/// ///
/// Provides list of all orders from local data source. /// Provides list of all orders from repository (Clean Architecture).
final class OrdersProvider final class OrdersProvider extends $AsyncNotifierProvider<Orders, List<Order>> {
extends $AsyncNotifierProvider<Orders, List<OrderModel>> {
/// Orders Provider /// Orders Provider
/// ///
/// Provides list of all orders from local data source. /// Provides list of all orders from repository (Clean Architecture).
const OrdersProvider._() const OrdersProvider._()
: super( : super(
from: null, from: null,
@@ -95,25 +41,24 @@ final class OrdersProvider
Orders create() => Orders(); Orders create() => Orders();
} }
String _$ordersHash() => r'7d2ae33e528260172495e8360f6879cb6e089766'; String _$ordersHash() => r'1a4712005f0d2fdd2d15e01b6dd9ea2adc428343';
/// Orders Provider /// Orders Provider
/// ///
/// Provides list of all orders from local data source. /// Provides list of all orders from repository (Clean Architecture).
abstract class _$Orders extends $AsyncNotifier<List<OrderModel>> { abstract class _$Orders extends $AsyncNotifier<List<Order>> {
FutureOr<List<OrderModel>> build(); FutureOr<List<Order>> build();
@$mustCallSuper @$mustCallSuper
@override @override
void runBuild() { void runBuild() {
final created = build(); final created = build();
final ref = final ref = this.ref as $Ref<AsyncValue<List<Order>>, List<Order>>;
this.ref as $Ref<AsyncValue<List<OrderModel>>, List<OrderModel>>;
final element = final element =
ref.element ref.element
as $ClassProviderElement< as $ClassProviderElement<
AnyNotifier<AsyncValue<List<OrderModel>>, List<OrderModel>>, AnyNotifier<AsyncValue<List<Order>>, List<Order>>,
AsyncValue<List<OrderModel>>, AsyncValue<List<Order>>,
Object?, Object?,
Object? Object?
>; >;
@@ -134,7 +79,7 @@ const selectedOrderStatusProvider = SelectedOrderStatusProvider._();
/// Tracks the currently selected order status filter. /// Tracks the currently selected order status filter.
/// null means "All" orders. /// null means "All" orders.
final class SelectedOrderStatusProvider final class SelectedOrderStatusProvider
extends $NotifierProvider<SelectedOrderStatus, OrderStatus?> { extends $NotifierProvider<SelectedOrderStatus, String?> {
/// Selected Order Status Provider /// Selected Order Status Provider
/// ///
/// Tracks the currently selected order status filter. /// Tracks the currently selected order status filter.
@@ -158,34 +103,34 @@ final class SelectedOrderStatusProvider
SelectedOrderStatus create() => SelectedOrderStatus(); SelectedOrderStatus create() => SelectedOrderStatus();
/// {@macro riverpod.override_with_value} /// {@macro riverpod.override_with_value}
Override overrideWithValue(OrderStatus? value) { Override overrideWithValue(String? value) {
return $ProviderOverride( return $ProviderOverride(
origin: this, origin: this,
providerOverride: $SyncValueProvider<OrderStatus?>(value), providerOverride: $SyncValueProvider<String?>(value),
); );
} }
} }
String _$selectedOrderStatusHash() => String _$selectedOrderStatusHash() =>
r'51834a8660a7f792e4075f76354e8a23a4fe9d7c'; r'24d7f26c87da85b04a6f7ad0691663ef50f9523f';
/// Selected Order Status Provider /// Selected Order Status Provider
/// ///
/// Tracks the currently selected order status filter. /// Tracks the currently selected order status filter.
/// null means "All" orders. /// null means "All" orders.
abstract class _$SelectedOrderStatus extends $Notifier<OrderStatus?> { abstract class _$SelectedOrderStatus extends $Notifier<String?> {
OrderStatus? build(); String? build();
@$mustCallSuper @$mustCallSuper
@override @override
void runBuild() { void runBuild() {
final created = build(); final created = build();
final ref = this.ref as $Ref<OrderStatus?, OrderStatus?>; final ref = this.ref as $Ref<String?, String?>;
final element = final element =
ref.element ref.element
as $ClassProviderElement< as $ClassProviderElement<
AnyNotifier<OrderStatus?, OrderStatus?>, AnyNotifier<String?, String?>,
OrderStatus?, String?,
Object?, Object?,
Object? Object?
>; >;
@@ -274,11 +219,11 @@ const filteredOrdersProvider = FilteredOrdersProvider._();
final class FilteredOrdersProvider final class FilteredOrdersProvider
extends extends
$FunctionalProvider< $FunctionalProvider<
AsyncValue<List<OrderModel>>, AsyncValue<List<Order>>,
List<OrderModel>, List<Order>,
FutureOr<List<OrderModel>> FutureOr<List<Order>>
> >
with $FutureModifier<List<OrderModel>>, $FutureProvider<List<OrderModel>> { with $FutureModifier<List<Order>>, $FutureProvider<List<Order>> {
/// Filtered Orders Provider /// Filtered Orders Provider
/// ///
/// Filters orders by selected status and search query. /// Filters orders by selected status and search query.
@@ -298,17 +243,17 @@ final class FilteredOrdersProvider
@$internal @$internal
@override @override
$FutureProviderElement<List<OrderModel>> $createElement( $FutureProviderElement<List<Order>> $createElement(
$ProviderPointer pointer, $ProviderPointer pointer,
) => $FutureProviderElement(pointer); ) => $FutureProviderElement(pointer);
@override @override
FutureOr<List<OrderModel>> create(Ref ref) { FutureOr<List<Order>> create(Ref ref) {
return filteredOrders(ref); return filteredOrders(ref);
} }
} }
String _$filteredOrdersHash() => r'4cc009352d3b09159c0fe107645634c3a4a81a7c'; String _$filteredOrdersHash() => r'04c5c87d7138b66987c8b45f878d445026ec8e19';
/// Orders Count by Status Provider /// Orders Count by Status Provider
/// ///
@@ -324,13 +269,11 @@ const ordersCountByStatusProvider = OrdersCountByStatusProvider._();
final class OrdersCountByStatusProvider final class OrdersCountByStatusProvider
extends extends
$FunctionalProvider< $FunctionalProvider<
AsyncValue<Map<OrderStatus, int>>, AsyncValue<Map<String, int>>,
Map<OrderStatus, int>, Map<String, int>,
FutureOr<Map<OrderStatus, int>> FutureOr<Map<String, int>>
> >
with with $FutureModifier<Map<String, int>>, $FutureProvider<Map<String, int>> {
$FutureModifier<Map<OrderStatus, int>>,
$FutureProvider<Map<OrderStatus, int>> {
/// Orders Count by Status Provider /// Orders Count by Status Provider
/// ///
/// Returns count of orders for each status. /// Returns count of orders for each status.
@@ -350,18 +293,18 @@ final class OrdersCountByStatusProvider
@$internal @$internal
@override @override
$FutureProviderElement<Map<OrderStatus, int>> $createElement( $FutureProviderElement<Map<String, int>> $createElement(
$ProviderPointer pointer, $ProviderPointer pointer,
) => $FutureProviderElement(pointer); ) => $FutureProviderElement(pointer);
@override @override
FutureOr<Map<OrderStatus, int>> create(Ref ref) { FutureOr<Map<String, int>> create(Ref ref) {
return ordersCountByStatus(ref); return ordersCountByStatus(ref);
} }
} }
String _$ordersCountByStatusHash() => String _$ordersCountByStatusHash() =>
r'85fe4fb85410855bb434b19fdc05c933c6e76235'; r'f6cd7f4eb47123d8e3bcfc04a82990301f3c2690';
/// Total Orders Count Provider /// Total Orders Count Provider
@@ -400,3 +343,152 @@ final class TotalOrdersCountProvider
} }
String _$totalOrdersCountHash() => r'ec1ab3a8d432033aa1f02d28e841e78eba06d63e'; String _$totalOrdersCountHash() => r'ec1ab3a8d432033aa1f02d28e841e78eba06d63e';
/// Order Status List Provider
///
/// Provides cached order status list with automatic refresh.
/// Uses cache-first strategy with API fallback.
@ProviderFor(orderStatusList)
const orderStatusListProvider = OrderStatusListProvider._();
/// Order Status List Provider
///
/// Provides cached order status list with automatic refresh.
/// Uses cache-first strategy with API fallback.
final class OrderStatusListProvider
extends
$FunctionalProvider<
AsyncValue<List<OrderStatus>>,
List<OrderStatus>,
FutureOr<List<OrderStatus>>
>
with
$FutureModifier<List<OrderStatus>>,
$FutureProvider<List<OrderStatus>> {
/// Order Status List Provider
///
/// Provides cached order status list with automatic refresh.
/// Uses cache-first strategy with API fallback.
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'f005726ad238164f7e0dece62476b39fd762e933';
/// Order Detail Provider
///
/// Provides detailed order information by order ID.
@ProviderFor(orderDetail)
const orderDetailProvider = OrderDetailFamily._();
/// Order Detail Provider
///
/// Provides detailed order information by order ID.
final class OrderDetailProvider
extends
$FunctionalProvider<
AsyncValue<OrderDetail>,
OrderDetail,
FutureOr<OrderDetail>
>
with $FutureModifier<OrderDetail>, $FutureProvider<OrderDetail> {
/// Order Detail Provider
///
/// Provides detailed order information by order ID.
const OrderDetailProvider._({
required OrderDetailFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'orderDetailProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$orderDetailHash();
@override
String toString() {
return r'orderDetailProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<OrderDetail> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<OrderDetail> create(Ref ref) {
final argument = this.argument as String;
return orderDetail(ref, argument);
}
@override
bool operator ==(Object other) {
return other is OrderDetailProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$orderDetailHash() => r'628b9102b54579b8bba5f9135d875730cf2066c0';
/// Order Detail Provider
///
/// Provides detailed order information by order ID.
final class OrderDetailFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<OrderDetail>, String> {
const OrderDetailFamily._()
: super(
retry: null,
name: r'orderDetailProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Order Detail Provider
///
/// Provides detailed order information by order ID.
OrderDetailProvider call(String orderId) =>
OrderDetailProvider._(argument: orderId, from: this);
@override
String toString() => r'orderDetailProvider';
}

View File

@@ -3,20 +3,18 @@
/// Displays order information in a card format. /// Displays order information in a card format.
library; library;
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:worker/core/database/models/enums.dart'; import 'package:worker/core/enums/status_color.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/orders/data/models/order_model.dart'; import 'package:worker/features/orders/domain/entities/order.dart';
/// Order Card Widget /// Order Card Widget
/// ///
/// Displays order details in a card with status indicator. /// Displays order details in a card with status indicator.
class OrderCard extends StatelessWidget { class OrderCard extends StatelessWidget {
/// Order to display /// Order to display
final OrderModel order; final Order order;
/// Tap callback /// Tap callback
final VoidCallback? onTap; final VoidCallback? onTap;
@@ -50,7 +48,7 @@ class OrderCard extends StatelessWidget {
children: [ children: [
// Order number // Order number
Text( Text(
'#${order.orderNumber}', '#${order.name}',
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -60,7 +58,7 @@ class OrderCard extends StatelessWidget {
// Amount // Amount
Text( Text(
currencyFormatter.format(order.finalAmount), currencyFormatter.format(order.grandTotal),
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -73,18 +71,13 @@ class OrderCard extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
// Order details // Order details
_buildDetailRow('Ngày đặt:', _formatDate(order.createdAt)), _buildDetailRow('Ngày đặt:', _formatDate(order.transactionDate)),
const SizedBox(height: 6), const SizedBox(height: 6),
_buildDetailRow( _buildDetailRow('Ngày giao:', _formatDate(order.deliveryDate)),
'Ngày giao:',
order.expectedDeliveryDate != null
? _formatDate(order.expectedDeliveryDate!)
: 'Chưa xác định',
),
const SizedBox(height: 6), const SizedBox(height: 6),
_buildDetailRow('Địa chỉ:', _getShortAddress()), _buildDetailRow('Địa chỉ:', order.address),
const SizedBox(height: 12), const SizedBox(height: 12),
// Status badge // Status badge
@@ -118,100 +111,50 @@ class OrderCard extends StatelessWidget {
/// Build status badge /// Build status badge
Widget _buildStatusBadge() { Widget _buildStatusBadge() {
final statusColor = _getStatusColor();
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getStatusColor(order.status).withValues(alpha: 0.1), color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: _getStatusColor(order.status).withValues(alpha: 0.3), color: statusColor.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
), ),
child: Text( child: Text(
_getStatusText(order.status), order.status,
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: _getStatusColor(order.status), color: statusColor,
), ),
), ),
); );
} }
/// Get status color /// Get status color from API status_color field
Color _getStatusColor(OrderStatus status) { Color _getStatusColor() {
switch (status) { // Parse statusColor from API (Warning, Success, Danger, Info, Secondary)
case OrderStatus.draft: final statusColorEnum = StatusColor.values.firstWhere(
return AppColors.grey500; (e) => e.name.toLowerCase() == order.statusColor.toLowerCase(),
case OrderStatus.pending: orElse: () => StatusColor.secondary,
return const Color(0xFFF59E0B); // warning/pending color );
case OrderStatus.confirmed: return statusColorEnum.color;
return const Color(0xFFF59E0B); // warning/pending color
case OrderStatus.processing:
return AppColors.info;
case OrderStatus.shipped:
return const Color(0xFF3B82F6); // blue
case OrderStatus.delivered:
return const Color(0xFF10B981); // green
case OrderStatus.completed:
return AppColors.success;
case OrderStatus.cancelled:
return AppColors.danger;
case OrderStatus.refunded:
return const Color(0xFFF97316); // orange
}
}
/// Get status text in Vietnamese
String _getStatusText(OrderStatus status) {
switch (status) {
case OrderStatus.draft:
return 'Nháp';
case OrderStatus.pending:
return 'Chờ xác nhận';
case OrderStatus.confirmed:
return 'Đã xác nhận';
case OrderStatus.processing:
return 'Đang xử lý';
case OrderStatus.shipped:
return 'Đang giao';
case OrderStatus.delivered:
return 'Đã giao';
case OrderStatus.completed:
return 'Hoàn thành';
case OrderStatus.cancelled:
return 'Đã hủy';
case OrderStatus.refunded:
return 'Đã hoàn tiền';
}
} }
/// Format date to dd/MM/yyyy /// Format date to dd/MM/yyyy
String _formatDate(DateTime date) { String _formatDate(String? dateString) {
return DateFormat('dd/MM/yyyy').format(date); if (dateString == null || dateString.isEmpty) {
} return 'Chưa xác định';
/// Get short address (city or district, city)
String _getShortAddress() {
if (order.shippingAddress == null) {
return 'Chưa có địa chỉ';
} }
try { try {
final addressJson = jsonDecode(order.shippingAddress!); final date = DateTime.parse(dateString);
final city = addressJson['city'] as String?; return DateFormat('dd/MM/yyyy').format(date);
final district = addressJson['district'] as String?;
if (district != null && city != null) {
return '$district, $city';
} else if (city != null) {
return city;
} else {
return 'Chưa có địa chỉ';
}
} catch (e) { } catch (e) {
return 'Chưa có địa chỉ'; return dateString;
} }
} }
} }

View File

@@ -26,6 +26,7 @@ import 'package:worker/features/loyalty/data/models/redeemed_gift_model.dart';
import 'package:worker/features/orders/data/models/invoice_model.dart'; import 'package:worker/features/orders/data/models/invoice_model.dart';
import 'package:worker/features/orders/data/models/order_item_model.dart'; import 'package:worker/features/orders/data/models/order_item_model.dart';
import 'package:worker/features/orders/data/models/order_model.dart'; import 'package:worker/features/orders/data/models/order_model.dart';
import 'package:worker/features/orders/data/models/order_status_model.dart';
import 'package:worker/features/orders/data/models/payment_line_model.dart'; import 'package:worker/features/orders/data/models/payment_line_model.dart';
import 'package:worker/features/products/data/models/category_model.dart'; import 'package:worker/features/products/data/models/category_model.dart';
import 'package:worker/features/products/data/models/product_model.dart'; import 'package:worker/features/products/data/models/product_model.dart';
@@ -67,6 +68,7 @@ extension HiveRegistrar on HiveInterface {
registerAdapter(OrderItemModelAdapter()); registerAdapter(OrderItemModelAdapter());
registerAdapter(OrderModelAdapter()); registerAdapter(OrderModelAdapter());
registerAdapter(OrderStatusAdapter()); registerAdapter(OrderStatusAdapter());
registerAdapter(OrderStatusModelAdapter());
registerAdapter(PaymentLineModelAdapter()); registerAdapter(PaymentLineModelAdapter());
registerAdapter(PaymentMethodAdapter()); registerAdapter(PaymentMethodAdapter());
registerAdapter(PaymentReminderModelAdapter()); registerAdapter(PaymentReminderModelAdapter());
@@ -125,6 +127,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
registerAdapter(OrderItemModelAdapter()); registerAdapter(OrderItemModelAdapter());
registerAdapter(OrderModelAdapter()); registerAdapter(OrderModelAdapter());
registerAdapter(OrderStatusAdapter()); registerAdapter(OrderStatusAdapter());
registerAdapter(OrderStatusModelAdapter());
registerAdapter(PaymentLineModelAdapter()); registerAdapter(PaymentLineModelAdapter());
registerAdapter(PaymentMethodAdapter()); registerAdapter(PaymentMethodAdapter());
registerAdapter(PaymentReminderModelAdapter()); registerAdapter(PaymentReminderModelAdapter());