rwadurian/frontend/mining-app/lib/presentation/widgets/qr_scanner_sheet.dart

368 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.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;
bool _isPickingImage = 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);
}
/// 从相册选择图片识别二维码
Future<void> _pickImageAndScan() async {
if (_isPickingImage || _hasScanned) return;
setState(() => _isPickingImage = true);
try {
final picker = ImagePicker();
final image = await picker.pickImage(source: ImageSource.gallery);
if (image == null) {
setState(() => _isPickingImage = false);
return;
}
final BarcodeCapture? capture = await _controller.analyzeImage(image.path);
if (capture != null && capture.barcodes.isNotEmpty) {
final code = capture.barcodes.first.rawValue;
if (code != null && code.isNotEmpty && !_hasScanned) {
_hasScanned = true;
if (mounted) Navigator.of(context).pop(code);
return;
}
}
// 未识别到二维码
if (mounted) {
setState(() => _isPickingImage = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('未在图片中识别到二维码'),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
debugPrint('[QR_SCAN] pickImage error: $e');
if (mounted) {
setState(() => _isPickingImage = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('图片识别失败,请重试'),
backgroundColor: Colors.red,
),
);
}
}
}
@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.symmetric(horizontal: 24, vertical: 16),
child: Column(
children: [
Text(
'将二维码放入框内,即可自动扫描',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 12),
GestureDetector(
onTap: _isPickingImage ? null : _pickImageAndScan,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.photo_library_outlined,
size: 18,
color: _isPickingImage ? Colors.grey : _orange,
),
const SizedBox(width: 6),
Text(
_isPickingImage ? '识别中...' : '从相册选择',
style: TextStyle(
fontSize: 14,
color: _isPickingImage ? Colors.grey : _orange,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
),
],
),
);
}
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);
}
// 方案2Uri.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;
}
}