From 6237a4915350c2bba878302970122b0204010692 Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 25 Dec 2025 21:54:10 -0800 Subject: [PATCH] =?UTF-8?q?feat(mobile-app):=20=E8=B4=A6=E6=9C=AC=E6=98=8E?= =?UTF-8?q?=E7=BB=86-=E8=AE=A4=E7=A7=8D=E6=94=AF=E4=BB=98=E4=BA=A4?= =?UTF-8?q?=E6=98=93=E6=94=AF=E6=8C=81=E6=9F=A5=E7=9C=8B=E5=92=8C=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 认种支付流水项添加点击事件和右侧箭头指示器 - 新增交易详情底部弹窗,显示交易金额、时间、订单号等信息 - 添加"查看合同"按钮,使用 flutter_pdfview 展示 PDF - 添加"下载合同"按钮,通过 share_plus 分享/保存文件 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../pages/ledger_detail_page.dart | 534 +++++++++++++++--- 1 file changed, 447 insertions(+), 87 deletions(-) diff --git a/frontend/mobile-app/lib/features/trading/presentation/pages/ledger_detail_page.dart b/frontend/mobile-app/lib/features/trading/presentation/pages/ledger_detail_page.dart index 072f8bab..1813200a 100644 --- a/frontend/mobile-app/lib/features/trading/presentation/pages/ledger_detail_page.dart +++ b/frontend/mobile-app/lib/features/trading/presentation/pages/ledger_detail_page.dart @@ -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 /// 构建流水项 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 _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 _viewContractPdf(String orderNo) async { + // 显示加载指示器 + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(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 _downloadContractPdf(String orderNo) async { + // 显示加载指示器 + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(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'); + }, + ), + ); + } }