feat(transfer): 添加扫码转账功能

- 添加 mobile_scanner 依赖用于二维码扫描
- 创建 QrScannerSheet 组件,支持底部弹窗扫码
- 发送积分值页面添加扫码按钮,扫描后自动填入收款方手机号
- 支持解析 durian://transfer?phone={phone} 格式的二维码

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-28 16:22:53 -08:00
parent 0c0750ce93
commit a2da841d59
3 changed files with 319 additions and 4 deletions

View File

@ -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) {

View File

@ -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;
}
}

View File

@ -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