dang ki du an

This commit is contained in:
Phuoc Nguyen
2025-11-26 11:21:35 +07:00
parent 7ef12fa83a
commit 3741239d83
5 changed files with 913 additions and 313 deletions

View File

@@ -11,6 +11,7 @@ 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/theme/colors.dart';
import 'package:worker/core/utils/extensions.dart';
import 'package:worker/features/orders/presentation/providers/invoices_provider.dart';
/// Payment Detail Page
@@ -65,16 +66,12 @@ class PaymentDetailPage extends ConsumerWidget {
orElse: () => invoices.first,
);
final currencyFormatter = NumberFormat.currency(
locale: 'vi_VN',
symbol: 'đ',
decimalDigits: 0,
);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 12,
children: [
// Invoice Header Card
_buildInvoiceHeader(
@@ -82,13 +79,11 @@ class PaymentDetailPage extends ConsumerWidget {
invoice.orderId,
invoice.issueDate,
invoice.status,
currencyFormatter,
invoice.totalAmount,
invoice.amountPaid,
invoice.amountRemaining,
),
const SizedBox(height: 16),
// Dates and Customer Info Card
_buildCustomerInfo(
@@ -97,26 +92,21 @@ class PaymentDetailPage extends ConsumerWidget {
invoice.isOverdue,
),
const SizedBox(height: 16),
// Product List Card
_buildProductList(),
const SizedBox(height: 16),
// Payment History Card
_buildPaymentHistory(
invoice.amountPaid,
invoice.issueDate,
currencyFormatter,
invoice.issueDate
),
const SizedBox(height: 16),
// Download Section Card
_buildDownloadSection(invoice.invoiceNumber),
const SizedBox(height: 16),
// Support Button
Container(
@@ -148,7 +138,6 @@ class PaymentDetailPage extends ConsumerWidget {
),
),
const SizedBox(height: 12),
// Payment Button
Container(
@@ -195,7 +184,6 @@ class PaymentDetailPage extends ConsumerWidget {
),
),
const SizedBox(height: 16),
],
),
);
@@ -236,7 +224,6 @@ class PaymentDetailPage extends ConsumerWidget {
String? orderId,
DateTime issueDate,
InvoiceStatus status,
NumberFormat currencyFormatter,
double totalAmount,
double amountPaid,
double amountRemaining,
@@ -289,12 +276,12 @@ class PaymentDetailPage extends ConsumerWidget {
children: [
_buildSummaryRow(
'Tổng tiền hóa đơn:',
currencyFormatter.format(totalAmount),
totalAmount.toVNCurrency,
),
const SizedBox(height: 12),
_buildSummaryRow(
'Đã thanh toán:',
currencyFormatter.format(amountPaid),
amountPaid.toVNCurrency,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
@@ -302,7 +289,7 @@ class PaymentDetailPage extends ConsumerWidget {
),
_buildSummaryRow(
'Còn lại:',
currencyFormatter.format(amountRemaining),
amountRemaining.toVNCurrency,
isHighlighted: true,
valueColor: amountRemaining > 0
? AppColors.danger
@@ -560,7 +547,6 @@ class PaymentDetailPage extends ConsumerWidget {
Widget _buildPaymentHistory(
double amountPaid,
DateTime paymentDate,
NumberFormat currencyFormatter,
) {
final hasHistory = amountPaid > 0;
@@ -572,11 +558,11 @@ class PaymentDetailPage extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
const Row(
children: [
FaIcon(FontAwesomeIcons.clockRotateLeft, color: AppColors.primaryBlue, size: 18),
const SizedBox(width: 8),
const Text(
SizedBox(width: 8),
Text(
'Lịch sử thanh toán',
style: TextStyle(
fontSize: 18,
@@ -604,10 +590,12 @@ class PaymentDetailPage extends ConsumerWidget {
color: AppColors.success.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const FaIcon(
FontAwesomeIcons.check,
color: AppColors.success,
size: 18,
child: const Center(
child: FaIcon(
FontAwesomeIcons.check,
color: AppColors.success,
size: 18,
),
),
),
const SizedBox(width: 16),
@@ -643,7 +631,7 @@ class PaymentDetailPage extends ConsumerWidget {
),
),
Text(
currencyFormatter.format(amountPaid),
amountPaid.toVNCurrency,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,

View File

@@ -1,6 +1,6 @@
/// Page: Payments Page
///
/// Displays list of invoices/payments with tab filters.
/// Displays list of invoices/payments.
library;
import 'package:flutter/material.dart';
@@ -8,7 +8,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.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/features/orders/data/models/invoice_model.dart';
import 'package:worker/features/orders/presentation/providers/invoices_provider.dart';
@@ -17,131 +16,31 @@ import 'package:worker/features/orders/presentation/widgets/invoice_card.dart';
/// Payments Page
///
/// Features:
/// - Tab bar for invoice status filtering
/// - List of invoice cards
/// - Pull-to-refresh
/// - Empty states for each tab
class PaymentsPage extends ConsumerStatefulWidget {
/// - Empty state
class PaymentsPage extends ConsumerWidget {
const PaymentsPage({super.key});
@override
ConsumerState<PaymentsPage> createState() => _PaymentsPageState();
}
class _PaymentsPageState extends ConsumerState<PaymentsPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final List<Map<String, String>> _tabs = [
{'key': 'all', 'label': 'Tất cả'},
{'key': 'unpaid', 'label': 'Chưa thanh toán'},
{'key': 'overdue', 'label': 'Quá hạn'},
{'key': 'paid', 'label': 'Đã thanh toán'},
];
@override
void initState() {
super.initState();
_tabController = TabController(length: _tabs.length, vsync: this);
_tabController.addListener(_onTabChanged);
}
@override
void dispose() {
_tabController
..removeListener(_onTabChanged)
..dispose();
super.dispose();
}
void _onTabChanged() {
setState(() {}); // Rebuild to show filtered list
}
/// Filter invoices based on tab key
List<InvoiceModel> _filterInvoices(
List<InvoiceModel> invoices,
String tabKey,
) {
var filtered = List<InvoiceModel>.from(invoices);
switch (tabKey) {
case 'unpaid':
// Unpaid tab: issued status only
filtered = filtered
.where(
(invoice) =>
invoice.status == InvoiceStatus.issued && !invoice.isPaid,
)
.toList();
break;
case 'overdue':
// Overdue tab: overdue status
filtered = filtered
.where(
(invoice) =>
invoice.status == InvoiceStatus.overdue || invoice.isOverdue,
)
.toList();
break;
case 'paid':
// Paid tab: paid status
filtered = filtered
.where(
(invoice) =>
invoice.status == InvoiceStatus.paid || invoice.isPaid,
)
.toList();
break;
case 'all':
default:
// All tab: no filtering
break;
}
// Sort by issue date (newest first)
filtered.sort((a, b) => b.issueDate.compareTo(a.issueDate));
return filtered;
}
/// Get counts for each tab
Map<String, int> _getCounts(List<InvoiceModel> invoices) {
return {
'all': invoices.length,
'unpaid': invoices
.where(
(invoice) =>
invoice.status == InvoiceStatus.issued && !invoice.isPaid,
)
.length,
'overdue': invoices
.where(
(invoice) =>
invoice.status == InvoiceStatus.overdue || invoice.isOverdue,
)
.length,
'paid': invoices
.where(
(invoice) => invoice.status == InvoiceStatus.paid || invoice.isPaid,
)
.length,
};
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final invoicesAsync = ref.watch(invoicesProvider);
return invoicesAsync.when(
data: (allInvoices) {
final counts = _getCounts(allInvoices);
data: (invoices) {
// Sort by issue date (newest first)
final sortedInvoices = List<InvoiceModel>.from(invoices)
..sort((a, b) => b.issueDate.compareTo(a.issueDate));
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
appBar: AppBar(
leading: IconButton(
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
icon: const FaIcon(
FontAwesomeIcons.arrowLeft,
color: Colors.black,
size: 20,
),
onPressed: () => context.pop(),
),
title: const Text(
@@ -150,123 +49,53 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
),
elevation: AppBarSpecs.elevation,
backgroundColor: AppColors.white,
foregroundColor: AppColors.grey900,
centerTitle: false,
bottom: TabBar(
controller: _tabController,
isScrollable: true,
tabAlignment: TabAlignment.start,
labelColor: AppColors.primaryBlue,
unselectedLabelColor: AppColors.grey500,
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
),
indicatorColor: AppColors.primaryBlue,
indicatorWeight: 3,
tabs: _tabs.map((tab) {
final count = counts[tab['key']] ?? 0;
return Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(tab['label']!),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
body: sortedInvoices.isEmpty
? _buildEmptyState(ref)
: RefreshIndicator(
onRefresh: () async {
await ref.read(invoicesProvider.notifier).refresh();
},
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: sortedInvoices.length,
itemBuilder: (context, index) {
final invoice = sortedInvoices[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: InvoiceCard(
invoice: invoice,
onTap: () {
context.push('/payments/${invoice.invoiceId}');
},
onPaymentTap: () {
context.push('/payments/${invoice.invoiceId}');
},
),
decoration: BoxDecoration(
color: _tabController.index == _tabs.indexOf(tab)
? AppColors.primaryBlue.withValues(alpha: 0.1)
: AppColors.grey100,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'$count',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: _tabController.index == _tabs.indexOf(tab)
? AppColors.primaryBlue
: AppColors.grey500,
),
),
),
],
);
},
),
);
}).toList(),
),
),
body: RefreshIndicator(
onRefresh: () async {
print('refresh');
await ref.read(invoicesProvider.notifier).refresh();
},
child: TabBarView(
controller: _tabController,
children: _tabs.map((tab) {
final filteredInvoices = _filterInvoices(
allInvoices,
tab['key']!,
);
return CustomScrollView(
slivers: [
// Invoices List
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: filteredInvoices.isEmpty
? _buildEmptyState(tab['label']!)
: SliverList(
delegate: SliverChildBuilderDelegate((
context,
index,
) {
final invoice = filteredInvoices[index];
return InvoiceCard(
invoice: invoice,
onTap: () {
context.push(
'/payments/${invoice.invoiceId}',
);
},
onPaymentTap: () {
context.push(
'/payments/${invoice.invoiceId}',
);
},
);
}, childCount: filteredInvoices.length),
),
),
],
);
}).toList(),
),
),
),
);
},
loading: () => Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
appBar: AppBar(
leading: IconButton(
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
icon: const FaIcon(
FontAwesomeIcons.arrowLeft,
color: Colors.black,
size: 20,
),
onPressed: () => context.pop(),
),
title: const Text(
'Danh sách hoá đơn',
'Thanh toán',
style: TextStyle(color: Colors.black),
),
elevation: AppBarSpecs.elevation,
backgroundColor: AppColors.white,
foregroundColor: AppColors.grey900,
centerTitle: false,
),
body: const Center(child: CircularProgressIndicator()),
@@ -275,16 +104,19 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
backgroundColor: const Color(0xFFF4F6F8),
appBar: AppBar(
leading: IconButton(
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
icon: const FaIcon(
FontAwesomeIcons.arrowLeft,
color: Colors.black,
size: 20,
),
onPressed: () => context.pop(),
),
title: const Text(
'Danh sách hoá đơn',
'Thanh toán',
style: TextStyle(color: Colors.black),
),
elevation: AppBarSpecs.elevation,
backgroundColor: AppColors.white,
foregroundColor: AppColors.grey900,
centerTitle: false,
),
body: Center(
@@ -319,54 +151,44 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
}
/// Build empty state
Widget _buildEmptyState(String tabLabel) {
String message;
IconData icon;
switch (tabLabel) {
case 'Chưa thanh toán':
message = 'Không có hóa đơn chưa thanh toán';
icon = FontAwesomeIcons.receipt;
break;
case 'Quá hạn':
message = 'Không có hóa đơn quá hạn';
icon = FontAwesomeIcons.triangleExclamation;
break;
case 'Đã thanh toán':
message = 'Không có hóa đơn đã thanh toán';
icon = FontAwesomeIcons.circleCheck;
break;
default:
message = 'Không có hóa đơn nào';
icon = FontAwesomeIcons.receipt;
}
return SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FaIcon(
icon,
size: 80,
color: AppColors.grey500.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.grey500,
Widget _buildEmptyState(WidgetRef ref) {
return RefreshIndicator(
onRefresh: () async {
await ref.read(invoicesProvider.notifier).refresh();
},
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
SizedBox(
height: 500,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FaIcon(
FontAwesomeIcons.receipt,
size: 80,
color: AppColors.grey500.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
const Text(
'Không có hóa đơn nào',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.grey500,
),
),
const SizedBox(height: 8),
const Text(
'Kéo xuống để làm mới',
style: TextStyle(fontSize: 14, color: AppColors.grey500),
),
],
),
),
const SizedBox(height: 8),
const Text(
'Kéo xuống để làm mới',
style: TextStyle(fontSize: 14, color: AppColors.grey500),
),
],
),
),
],
),
);
}