292 lines
9.2 KiB
Dart
292 lines
9.2 KiB
Dart
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} 或 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;
|
||
}
|
||
}
|