275 lines
8.2 KiB
Dart
275 lines
8.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}
|
|
/// 返回手机号,解析失败返回 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;
|
|
}
|
|
}
|