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} 或 durian://transfer?phone={phone} /// 返回手机号,解析失败返回 null String? parseTransferQrCode(String qrCode) { try { // 去除首尾空白及不可见字符(BOM、零宽空格等) final trimmed = qrCode.trim().replaceAll(RegExp(r'[\u200B-\u200D\uFEFF\u00A0]'), ''); debugPrint('[QR_SCAN] raw length=${qrCode.length}, trimmed length=${trimmed.length}'); debugPrint('[QR_SCAN] raw codeUnits=${qrCode.codeUnits}'); debugPrint('[QR_SCAN] trimmed="$trimmed"'); // 方案1:宽松正则 - 只要包含 durian://transfer 和 phone=11位数字 final regex = RegExp(r'durian://transfer[/?].*?phone=(\d{11})'); final match = regex.firstMatch(trimmed); if (match != null) { debugPrint('[QR_SCAN] regex matched, phone=${match.group(1)}'); return match.group(1); } // 方案2:Uri.parse final uri = Uri.tryParse(trimmed); if (uri != null && uri.scheme == 'durian') { final phone = uri.queryParameters['phone']; if (phone != null && RegExp(r'^\d{11}$').hasMatch(phone)) { debugPrint('[QR_SCAN] Uri.parse matched, phone=$phone'); return phone; } } // 方案3:最宽松兜底 - 字符串中任何 phone=11位数字 final fallback = RegExp(r'phone=(\d{11})').firstMatch(trimmed); if (fallback != null) { debugPrint('[QR_SCAN] fallback matched, phone=${fallback.group(1)}'); return fallback.group(1); } debugPrint('[QR_SCAN] all parsing failed for: "$trimmed"'); return null; } catch (e) { debugPrint('[QR_SCAN] exception: $e'); return null; } }