feat(mobile-app): 账本明细-认种支付交易支持查看和下载合同

- 认种支付流水项添加点击事件和右侧箭头指示器
- 新增交易详情底部弹窗,显示交易金额、时间、订单号等信息
- 添加"查看合同"按钮,使用 flutter_pdfview 展示 PDF
- 添加"下载合同"按钮,通过 share_plus 分享/保存文件

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-25 21:54:10 -08:00
parent b63aa0737c
commit 6237a49153
1 changed files with 447 additions and 87 deletions

View File

@ -1,6 +1,10 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:flutter_pdfview/flutter_pdfview.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../core/services/wallet_service.dart';
import '../../../../core/utils/date_utils.dart';
@ -922,112 +926,468 @@ class _LedgerDetailPageState extends ConsumerState<LedgerDetailPage>
///
Widget _buildLedgerItem(LedgerEntry entry) {
final isIncome = entry.isIncome;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
//
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isIncome
? const Color(0x1A4CAF50)
: const Color(0x1AE53935),
borderRadius: BorderRadius.circular(10),
// "认种支付"
final bool isClickable = entry.entryType == 'PLANT_PAYMENT' && entry.refOrderId != null;
return GestureDetector(
onTap: isClickable ? () => _showTransactionDetail(entry) : null,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 8,
offset: const Offset(0, 2),
),
child: Icon(
isIncome ? Icons.arrow_downward : Icons.arrow_upward,
size: 20,
color: isIncome ? const Color(0xFF4CAF50) : const Color(0xFFE53935),
],
),
child: Row(
children: [
//
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isIncome
? const Color(0x1A4CAF50)
: const Color(0x1AE53935),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
isIncome ? Icons.arrow_downward : Icons.arrow_upward,
size: 20,
color: isIncome ? const Color(0xFF4CAF50) : const Color(0xFFE53935),
),
),
),
const SizedBox(width: 12),
//
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.entryTypeName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 4),
Text(
_formatDate(entry.createdAt),
style: const TextStyle(
fontSize: 10,
color: Color(0x995D4037),
),
),
if (entry.memo != null && entry.memo!.isNotEmpty) ...[
const SizedBox(height: 2),
const SizedBox(width: 12),
//
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.memo!,
entry.entryTypeName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 4),
Text(
_formatDate(entry.createdAt),
style: const TextStyle(
fontSize: 10,
color: Color(0x995D4037),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
//
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Text(
'${isIncome ? '+' : ''}${_formatAmount(entry.amount)}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: isIncome ? const Color(0xFF4CAF50) : const Color(0xFFE53935),
),
),
),
if (entry.balanceAfter != null) ...[
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Text(
'余额: ${_formatAmount(entry.balanceAfter!)}',
if (entry.memo != null && entry.memo!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
entry.memo!,
style: const TextStyle(
fontSize: 10,
color: Color(0x995D4037),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
//
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 100),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Text(
'${isIncome ? '+' : ''}${_formatAmount(entry.amount)}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: isIncome ? const Color(0xFF4CAF50) : const Color(0xFFE53935),
),
),
),
if (entry.balanceAfter != null) ...[
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Text(
'余额: ${_formatAmount(entry.balanceAfter!)}',
style: const TextStyle(
fontSize: 10,
color: Color(0x995D4037),
),
),
),
],
],
),
),
//
if (isClickable) ...[
const SizedBox(width: 8),
const Icon(
Icons.chevron_right,
size: 20,
color: Color(0x995D4037),
),
],
],
),
),
);
}
///
Future<void> _showTransactionDetail(LedgerEntry entry) async {
if (entry.refOrderId == null) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => _TransactionDetailSheet(
entry: entry,
onViewContract: () => _viewContractPdf(entry.refOrderId!),
onDownloadContract: () => _downloadContractPdf(entry.refOrderId!),
),
);
}
/// PDF
Future<void> _viewContractPdf(String orderNo) async {
//
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFFD4AF37)),
),
),
);
try {
final contractService = ref.read(contractSigningServiceProvider);
final pdfBytes = await contractService.downloadContractPdf(orderNo);
//
final tempDir = await getTemporaryDirectory();
final pdfFile = File('${tempDir.path}/contract_$orderNo.pdf');
await pdfFile.writeAsBytes(pdfBytes);
if (!mounted) return;
Navigator.of(context).pop(); //
// PDF
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => _ContractPdfViewerPage(
pdfPath: pdfFile.path,
contractNo: orderNo,
),
),
);
} catch (e) {
if (!mounted) return;
Navigator.of(context).pop(); //
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('加载合同失败: $e'),
backgroundColor: Colors.red,
),
);
}
}
/// PDF
Future<void> _downloadContractPdf(String orderNo) async {
//
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFFD4AF37)),
),
),
);
try {
final contractService = ref.read(contractSigningServiceProvider);
final pdfBytes = await contractService.downloadContractPdf(orderNo);
//
final directory = await getApplicationDocumentsDirectory();
final fileName = 'contract_$orderNo.pdf';
final pdfFile = File('${directory.path}/$fileName');
await pdfFile.writeAsBytes(pdfBytes);
if (!mounted) return;
Navigator.of(context).pop(); //
// /
await Share.shareXFiles(
[XFile(pdfFile.path)],
subject: '认种合同 - $orderNo',
);
} catch (e) {
if (!mounted) return;
Navigator.of(context).pop(); //
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('下载合同失败: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
///
class _TransactionDetailSheet extends StatelessWidget {
final LedgerEntry entry;
final VoidCallback onViewContract;
final VoidCallback onDownloadContract;
const _TransactionDetailSheet({
required this.entry,
required this.onViewContract,
required this.onDownloadContract,
});
@override
Widget build(BuildContext context) {
final isIncome = entry.isIncome;
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
//
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFFE0E0E0),
borderRadius: BorderRadius.circular(2),
),
),
//
Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isIncome
? const Color(0x1A4CAF50)
: const Color(0x1AE53935),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.description_outlined,
size: 24,
color: isIncome ? const Color(0xFF4CAF50) : const Color(0xFFE53935),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.entryTypeName,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 4),
Text(
'订单号: ${entry.refOrderId ?? '-'}',
style: const TextStyle(
fontSize: 12,
color: Color(0x995D4037),
),
),
],
),
),
],
],
),
),
// 线
Container(
height: 1,
color: const Color(0x1A8B5A2B),
),
//
Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
_buildDetailRow('交易金额', '${isIncome ? '+' : ''}${_formatAmount(entry.amount)} 绿积分',
color: isIncome ? const Color(0xFF4CAF50) : const Color(0xFFE53935)),
if (entry.balanceAfter != null)
_buildDetailRow('交易后余额', '${_formatAmount(entry.balanceAfter!)} 绿积分'),
_buildDetailRow('交易时间', _formatDateTime(entry.createdAt)),
if (entry.memo != null && entry.memo!.isNotEmpty)
_buildDetailRow('备注', entry.memo!),
],
),
),
// 线
Container(
height: 1,
color: const Color(0x1A8B5A2B),
),
//
Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pop();
onViewContract();
},
icon: const Icon(Icons.visibility_outlined, size: 18),
label: const Text('查看合同'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF5D4037),
side: const BorderSide(color: Color(0xFFD4AF37)),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
Navigator.of(context).pop();
onDownloadContract();
},
icon: const Icon(Icons.download_outlined, size: 18),
label: const Text('下载合同'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFD4AF37),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
),
),
],
),
),
);
}
Widget _buildDetailRow(String label, String value, {Color? color}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
color: Color(0x995D4037),
),
),
Flexible(
child: Text(
value,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: color ?? const Color(0xFF5D4037),
),
textAlign: TextAlign.right,
),
),
],
),
);
}
String _formatAmount(double amount) {
final formatter = NumberFormat('#,##0.00', 'zh_CN');
return formatter.format(amount);
}
String _formatDateTime(DateTime date) {
return DateTimeUtils.formatDateTime(date);
}
}
/// PDF
class _ContractPdfViewerPage extends StatelessWidget {
final String pdfPath;
final String contractNo;
const _ContractPdfViewerPage({
required this.pdfPath,
required this.contractNo,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: const Text('认种合同'),
backgroundColor: const Color(0xFFD4AF37),
foregroundColor: Colors.white,
elevation: 0,
),
body: PDFView(
filePath: pdfPath,
enableSwipe: true,
swipeHorizontal: false,
autoSpacing: true,
pageFling: true,
pageSnap: true,
fitPolicy: FitPolicy.BOTH,
onError: (error) {
debugPrint('PDF 加载错误: $error');
},
onPageError: (page, error) {
debugPrint('PDF 页面 $page 加载错误: $error');
},
),
);
}
}