update order detail

This commit is contained in:
Phuoc Nguyen
2025-11-24 17:00:11 +07:00
parent 75d6507719
commit 1851d60038
9 changed files with 1238 additions and 455 deletions

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

@@ -5,8 +5,10 @@ library;
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/payment_term.dart';
import 'package:worker/features/orders/domain/repositories/order_repository.dart';
@@ -40,6 +42,17 @@ class OrderRepositoryImpl implements OrderRepository {
}
}
@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
Future<List<OrderStatus>> getOrderStatusList() async {
try {

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

@@ -4,6 +4,7 @@
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/payment_term.dart';
@@ -15,6 +16,9 @@ abstract class OrderRepository {
int limitPageLength = 0,
});
/// Get order detail by ID
Future<OrderDetail> getOrderDetail(String orderId);
/// Get list of available order statuses
Future<List<OrderStatus>> getOrderStatusList();

View File

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

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

View File

@@ -5,6 +5,7 @@ library;
import 'package:riverpod_annotation/riverpod_annotation.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/presentation/providers/order_repository_provider.dart';
@@ -179,3 +180,12 @@ 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

@@ -398,3 +398,97 @@ final class OrderStatusListProvider
}
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';
}