From a2da841d599a0d09eeeb26ddb205815ca6b116b9 Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 28 Jan 2026 16:22:53 -0800 Subject: [PATCH] =?UTF-8?q?feat(transfer):=20=E6=B7=BB=E5=8A=A0=E6=89=AB?= =?UTF-8?q?=E7=A0=81=E8=BD=AC=E8=B4=A6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 mobile_scanner 依赖用于二维码扫描 - 创建 QrScannerSheet 组件,支持底部弹窗扫码 - 发送积分值页面添加扫码按钮,扫描后自动填入收款方手机号 - 支持解析 durian://transfer?phone={phone} 格式的二维码 Co-Authored-By: Claude Opus 4.5 --- .../pages/asset/send_shares_page.dart | 48 ++- .../widgets/qr_scanner_sheet.dart | 274 ++++++++++++++++++ frontend/mining-app/pubspec.yaml | 1 + 3 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 frontend/mining-app/lib/presentation/widgets/qr_scanner_sheet.dart diff --git a/frontend/mining-app/lib/presentation/pages/asset/send_shares_page.dart b/frontend/mining-app/lib/presentation/pages/asset/send_shares_page.dart index 4f7aeadd..c7263aa5 100644 --- a/frontend/mining-app/lib/presentation/pages/asset/send_shares_page.dart +++ b/frontend/mining-app/lib/presentation/pages/asset/send_shares_page.dart @@ -7,6 +7,7 @@ import '../../../core/utils/format_utils.dart'; import '../../providers/user_providers.dart'; import '../../providers/asset_providers.dart'; import '../../providers/transfer_providers.dart'; +import '../../widgets/qr_scanner_sheet.dart'; class SendSharesPage extends ConsumerStatefulWidget { const SendSharesPage({super.key}); @@ -153,8 +154,18 @@ class _SendSharesPageState extends ConsumerState { borderSide: const BorderSide(color: _orange, width: 2), ), counterText: '', - suffixIcon: _phoneController.text.isNotEmpty - ? IconButton( + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 扫码按钮 + IconButton( + icon: const Icon(Icons.qr_code_scanner, color: _orange), + onPressed: _scanQrCode, + tooltip: '扫描二维码', + ), + // 清除按钮 + if (_phoneController.text.isNotEmpty) + IconButton( icon: const Icon(Icons.clear, color: _grayText), onPressed: () { setState(() { @@ -163,8 +174,9 @@ class _SendSharesPageState extends ConsumerState { _recipientNickname = null; }); }, - ) - : null, + ), + ], + ), ), onChanged: (value) { setState(() { @@ -461,6 +473,34 @@ class _SendSharesPageState extends ConsumerState { ); } + /// 扫描二维码 + Future _scanQrCode() async { + final result = await QrScannerSheet.show(context); + if (result == null) return; + + // 解析二维码内容 + final phone = parseTransferQrCode(result); + if (phone != null) { + setState(() { + _phoneController.text = phone; + _isRecipientVerified = false; + _recipientNickname = null; + }); + // 自动验证收款方 + _verifyRecipient(); + } else { + // 二维码格式不正确 + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('无效的转账二维码'), + backgroundColor: _red, + ), + ); + } + } + } + Future _verifyRecipient() async { final phone = _phoneController.text.trim(); if (phone.length != 11) { diff --git a/frontend/mining-app/lib/presentation/widgets/qr_scanner_sheet.dart b/frontend/mining-app/lib/presentation/widgets/qr_scanner_sheet.dart new file mode 100644 index 00000000..f03abe5a --- /dev/null +++ b/frontend/mining-app/lib/presentation/widgets/qr_scanner_sheet.dart @@ -0,0 +1,274 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +/// 二维码扫描底部弹窗 +/// 扫描成功后自动关闭并返回扫描结果 +class QrScannerSheet extends StatefulWidget { + const QrScannerSheet({super.key}); + + /// 显示扫码底部弹窗 + /// 返回扫描到的二维码内容,取消或失败返回 null + static Future show(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => const QrScannerSheet(), + ); + } + + @override + State createState() => _QrScannerSheetState(); +} + +class _QrScannerSheetState extends State { + static const Color _orange = Color(0xFFFF6B00); + static const Color _darkText = Color(0xFF1F2937); + + final MobileScannerController _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.normal, + facing: CameraFacing.back, + ); + + bool _hasScanned = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (_hasScanned) return; + + final List barcodes = capture.barcodes; + if (barcodes.isEmpty) return; + + final String? code = barcodes.first.rawValue; + if (code == null || code.isEmpty) return; + + _hasScanned = true; + Navigator.of(context).pop(code); + } + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + + return Container( + height: screenHeight * 0.7, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Column( + children: [ + // 顶部拖动条 + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + + // 标题栏 + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.close, color: _darkText), + onPressed: () => Navigator.of(context).pop(), + ), + const Expanded( + child: Text( + '扫描二维码', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: _darkText, + ), + ), + ), + // 闪光灯按钮 + IconButton( + icon: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, child) { + return Icon( + state.torchState == TorchState.on + ? Icons.flash_on + : Icons.flash_off, + color: state.torchState == TorchState.on + ? _orange + : _darkText, + ); + }, + ), + onPressed: () => _controller.toggleTorch(), + ), + ], + ), + ), + + // 扫描区域 + Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 32), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + children: [ + // 相机预览 + MobileScanner( + controller: _controller, + onDetect: _onDetect, + ), + + // 扫描框装饰 + Center( + child: Container( + width: 250, + height: 250, + decoration: BoxDecoration( + border: Border.all( + color: _orange.withOpacity(0.8), + width: 2, + ), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + + // 四角装饰 + Center( + child: SizedBox( + width: 250, + height: 250, + child: Stack( + children: [ + // 左上角 + Positioned( + left: 0, + top: 0, + child: _buildCorner( + borderTop: true, + borderLeft: true, + ), + ), + // 右上角 + Positioned( + right: 0, + top: 0, + child: _buildCorner( + borderTop: true, + borderRight: true, + ), + ), + // 左下角 + Positioned( + left: 0, + bottom: 0, + child: _buildCorner( + borderBottom: true, + borderLeft: true, + ), + ), + // 右下角 + Positioned( + right: 0, + bottom: 0, + child: _buildCorner( + borderBottom: true, + borderRight: true, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + + // 提示文字 + Padding( + padding: const EdgeInsets.all(24), + child: Text( + '将二维码放入框内,即可自动扫描', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ), + ], + ), + ); + } + + Widget _buildCorner({ + bool borderTop = false, + bool borderBottom = false, + bool borderLeft = false, + bool borderRight = false, + }) { + return Container( + width: 24, + height: 24, + decoration: BoxDecoration( + border: Border( + top: borderTop + ? const BorderSide(color: _orange, width: 4) + : BorderSide.none, + bottom: borderBottom + ? const BorderSide(color: _orange, width: 4) + : BorderSide.none, + left: borderLeft + ? const BorderSide(color: _orange, width: 4) + : BorderSide.none, + right: borderRight + ? const BorderSide(color: _orange, width: 4) + : BorderSide.none, + ), + ), + ); + } +} + +/// 解析转账二维码 +/// 格式: durian://transfer?phone={phone} +/// 返回手机号,解析失败返回 null +String? parseTransferQrCode(String qrCode) { + try { + final uri = Uri.parse(qrCode); + + // 检查 scheme + if (uri.scheme != 'durian') return null; + + // 检查 host (即 authority 部分) + if (uri.host != 'transfer') return null; + + // 获取 phone 参数 + final phone = uri.queryParameters['phone']; + if (phone == null || phone.isEmpty) return null; + + // 简单验证手机号格式 + if (phone.length != 11 || !RegExp(r'^\d{11}$').hasMatch(phone)) { + return null; + } + + return phone; + } catch (e) { + return null; + } +} diff --git a/frontend/mining-app/pubspec.yaml b/frontend/mining-app/pubspec.yaml index 11af2d94..c7de54b8 100644 --- a/frontend/mining-app/pubspec.yaml +++ b/frontend/mining-app/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: cached_network_image: ^3.3.0 shimmer: ^3.0.0 qr_flutter: ^4.1.0 + mobile_scanner: ^5.1.1 # 图表 fl_chart: ^0.64.0