feat(transfer): 添加扫码转账功能
- 添加 mobile_scanner 依赖用于二维码扫描
- 创建 QrScannerSheet 组件,支持底部弹窗扫码
- 发送积分值页面添加扫码按钮,扫描后自动填入收款方手机号
- 支持解析 durian://transfer?phone={phone} 格式的二维码
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0c0750ce93
commit
a2da841d59
|
|
@ -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<SendSharesPage> {
|
|||
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<SendSharesPage> {
|
|||
_recipientNickname = null;
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
|
|
@ -461,6 +473,34 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 扫描二维码
|
||||
Future<void> _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<void> _verifyRecipient() async {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.length != 11) {
|
||||
|
|
|
|||
|
|
@ -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<String?> show(BuildContext context) {
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => const QrScannerSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<QrScannerSheet> createState() => _QrScannerSheetState();
|
||||
}
|
||||
|
||||
class _QrScannerSheetState extends State<QrScannerSheet> {
|
||||
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<Barcode> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue