v1.2.1 待发布

This commit is contained in:
2025-12-22 16:15:32 +08:00
parent 21a528d6d1
commit 9ba152b3c3
5 changed files with 312 additions and 272 deletions

View File

@@ -1,17 +1,13 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:getx_scaffold/getx_scaffold.dart';
import 'package:image/image.dart' as img;
import 'package:image_picker/image_picker.dart';
import 'package:ln_jq_app/common/model/base_model.dart';
import 'package:ln_jq_app/common/model/vehicle_info.dart';
import 'package:ln_jq_app/storage_service.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
import 'package:zxing_lib/common.dart';
import 'package:zxing_lib/qrcode.dart';
import 'package:zxing_lib/zxing.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:permission_handler/permission_handler.dart';
class QrCodeController extends GetxController
with BaseControllerMixin, GetSingleTickerProviderStateMixin {
@@ -22,11 +18,11 @@ class QrCodeController extends GetxController
late final AnimationController animationController;
late final Animation<double> scanAnimation;
// --- QR Scanning ---
final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
QRViewController? qrViewController;
final Rx<Barcode?> result = Rx<Barcode?>(null);
// --- 使用 MobileScanner 的控制器 ---
final MobileScannerController scannerController = MobileScannerController();
final RxBool isFlashOn = false.obs;
final RxBool isProcessingResult = false.obs;
@override
void onInit() {
@@ -37,140 +33,114 @@ class QrCodeController extends GetxController
duration: const Duration(milliseconds: 2500),
vsync: this,
);
scanAnimation = Tween<double>(begin: 0, end: 1).animate(animationController);
scanAnimation =
Tween<double>(begin: 0, end: 1).animate(animationController);
animationController.repeat(reverse: false);
}
/// 当 QRView 创建时调用
void onQRViewCreated(QRViewController controller) {
this.qrViewController = controller;
// 监听扫描到的数据
controller.scannedDataStream.listen((scanData) {
if (scanData.code != null && result.value?.code != scanData.code) {
result.value = scanData;
qrViewController?.pauseCamera();
/// MobileScanner 的 onDetect 回调方法
void onDetect(BarcodeCapture capture) {
if (isProcessingResult.value) return;
animationController.stop();
renderResult(scanData.code!);
}
});
final Barcode? barcode = capture.barcodes.firstOrNull;
if (barcode?.rawValue != null && barcode!.rawValue!.isNotEmpty) {
isProcessingResult.value = true;
scannerController.stop();
animationController.stop();
print("相机识别到的内容: ${barcode.rawValue!}");
renderResult(barcode.rawValue!);
}
}
/// 恢复扫描状态
void resumeScanner() {
result.value = null;
qrViewController?.resumeCamera();
isProcessingResult.value = false;
try {
scannerController.start();
} catch (e) {
print("无法重启相机: $e");
}
animationController.repeat(reverse: false);
}
/// 从相册选择图片并扫描二维码
void scanFromGallery() async {
try {
final XFile? imageFile = await ImagePicker().pickImage(source: ImageSource.gallery);
if (imageFile == null) return; // 用户取消了选择
qrViewController?.pauseCamera();
animationController.stop();
String? scanResult;
try {
final image = img.decodeImage(await File(imageFile.path).readAsBytes());
if (image != null) {
//扫描图片
final pixels = Int32List.fromList(
image.map((pixel) {
return (pixel.a.toInt() << 24) |
(pixel.r.toInt() << 16) |
(pixel.g.toInt() << 8) |
pixel.b.toInt();
}).toList(),
);
final source = RGBLuminanceSource(image.width, image.height, pixels);
final bitmap = BinaryBitmap(HybridBinarizer(source));
final reader = QRCodeReader();
final result = reader.decode(bitmap);
scanResult = result.text;
}
} on NotFoundException {
scanResult = null;
} catch (e) {
//异常
scanResult = null;
final XFile? imageFile =
await ImagePicker().pickImage(source: ImageSource.gallery);
if (imageFile == null) {
return;
}
if (scanResult != null) {
scannerController.stop();
animationController.stop();
showLoading("正在识别...");
final BarcodeCapture? capture =
await scannerController.analyzeImage(imageFile.path);
dismissLoading();
final Barcode? firstBarcode = capture?.barcodes.firstOrNull;
if (firstBarcode?.rawValue != null &&
firstBarcode!.rawValue!.isNotEmpty) {
final String scanResult = firstBarcode.rawValue!;
print("相册识别到的内容: $scanResult");
renderResult(scanResult);
} else {
showErrorToast('未识别到二维码');
resumeScanner();
}
} catch (e) {
showErrorToast('从相册选择失败');
} catch (e, stackTrace) {
dismissLoading();
showErrorToast('从相册选择失败,请稍后重试');
print("scanFromGallery Error: $e\n$stackTrace");
resumeScanner();
}
}
/// 切换闪光灯
void toggleFlash() async {
await qrViewController?.toggleFlash();
isFlashOn.value = (await qrViewController?.getFlashStatus()) ?? false;
try {
await scannerController.toggleTorch();
final currentTorchState = scannerController.value.torchState;
isFlashOn.value = currentTorchState == TorchState.on;
} catch (e) {
print("切换闪光灯失败: $e");
showErrorToast("无法打开闪光灯");
}
}
/// 翻转相机
void flipCamera() async {
await qrViewController?.flipCamera();
await scannerController.switchCamera();
}
/// 请求相机权限
void requestPermission() async {
if (Platform.isIOS) {
var status = await Permission.camera.request();
if (status.isGranted) {
} else if (status.isPermanentlyDenied) {
openAppSettings();
} else {
showErrorToast('需要相机权限才能扫描二维码');
}
return;
}
final bool results = await requestCameraPermission();
if (!results) {
showErrorToast('相机权限未被授予,请到权限管理中打开');
var status = await Permission.camera.request();
if (!status.isGranted) {
showErrorToast('请授予相机权限以使用扫描功能');
Get.back();
}
}
void requestPhotoPermission() async {
if (Platform.isAndroid) {
final bool results = await requestPhotosPermission();
if (!results) {
showErrorToast('相册权限未被授予,请到权限管理中打开');
} else {
scanFromGallery();
}
}
if (Platform.isIOS) {
var status = await Permission.photos.request();
print("权限状态: $status"); // 在控制台看这个输出
if (status.isGranted) {
scanFromGallery();
} else if (status.isPermanentlyDenied) {
openAppSettings();
} else {
showErrorToast('需要相册权限才能从相册中选择图片');
}
var status = await Permission.photos.request();
if (status.isGranted) {
scanFromGallery();
} else if (status.isPermanentlyDenied) {
openAppSettings();
} else {
showErrorToast('需要相册权限才能从相册中选择图片');
}
}
//扫码结果处理 //如果绑定接口返回的data为null 需要手动编辑车牌
/// 处理扫描结果
void renderResult(String resultStr, {plateNumber}) async {
showLoading("正在获取车辆信息...");
try {
/*var responseData = await HttpService.to.get(
"appointment/truck/base-info?vin=$resultStr",
);*/
final Map<String, dynamic> requestData = {
"code": resultStr,
"phone": StorageService.to.phone,
@@ -184,6 +154,7 @@ class QrCodeController extends GetxController
);
if (responseData == null) {
dismissLoading();
showToast('无法获取车辆信息,请检查网络或稍后重试');
resumeScanner();
return;
@@ -192,62 +163,76 @@ class QrCodeController extends GetxController
if (result.code != 0) {
showToast(result.error);
dismissLoading();
resumeScanner(); // 绑定失败也要恢复扫描
return;
}
if (result.data == null) {
dismissLoading();
showBindDialog(resultStr);
return;
}
final vehicle = VehicleInfo.fromJson(result.data as Map<String, dynamic>);
//保存使用
await StorageService.to.saveVehicleInfo(vehicle);
dismissLoading();
Get.back(result: true);
} on DioException catch (e) {
} on DioException catch (_) {
showErrorToast("网络请求失败,请稍后重试");
resumeScanner();
} catch (e, stackTrace) {
showErrorToast("处理失败,请稍后重");
resumeScanner(); // 未知异常,恢复扫描
} catch (e, _) {
showErrorToast("处理失败,请稍后重");
resumeScanner();
} finally {
dismissLoading();
if (Get.isDialogOpen ?? false) {
dismissLoading();
}
}
}
/// 显示绑定确认对话框
void showBindDialog(String resultStr) {
final TextEditingController plateNumberController = TextEditingController();
DialogX.to.showNoticeDialog(
icon: DialogIcon.info,
// 使用 showConfirmDialog它有 onCancel 回调
DialogX.to.showConfirmDialog(
title: '请输入车牌号',
barrierDismissible: false,
content: TextField(
controller: plateNumberController, // 绑定 controller
autofocus: false, // 弹窗出现时自动获取焦点,方便用户直接输入
controller: plateNumberController,
autofocus: false,
decoration: const InputDecoration(
hintText: '请输入完整的车牌号',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12.0),
),
),
confirmText: '确认',
confirmText: '确认绑定',
cancelText: '取消', // showConfirmDialog 有 cancelText
onConfirm: () {
final String plateNumber = plateNumberController.text.trim();
if (plateNumber.isEmpty) {
resumeScanner();
showToast("请输入车牌号");
return;
// 返回 false 可以阻止弹窗关闭,让用户继续输入
return false;
}
renderResult(resultStr, plateNumber: plateNumber);
//关闭弹窗
return true;
},
onCancel: () {
// 如果用户点击取消,恢复扫描
resumeScanner();
},
);
}
@override
void onClose() {
qrViewController?.dispose();
scannerController.dispose();
animationController.dispose();
super.onClose();
}
}

View File

@@ -1,148 +1,155 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:getx_scaffold/getx_scaffold.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
import 'package:ln_jq_app/common/styles/theme.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'controller.dart';
class QrCodePage extends GetView<QrCodeController> {
const QrCodePage({Key? key}) : super(key: key);
const QrCodePage({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<QrCodeController>(
init: QrCodeController(),
id: 'qrcode',
builder: (_) {
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
title: const Text('扫码', style: TextStyle(color: Colors.white)),
centerTitle: true,
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, color: Colors.white),
onPressed: () => Get.back(),
Get.put(QrCodeController());
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
title: const Text('扫码', style: TextStyle(color: Colors.white)),
centerTitle: true,
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, color: Colors.white),
onPressed: () => Get.back(),
),
actions: [
TextButton(
onPressed: controller.requestPhotoPermission,
child: const Text('相册', style: TextStyle(color: Colors.white, fontSize: 16)),
),
],
),
body: Stack(
alignment: Alignment.center,
children: [
// 1. 使用 MobileScanner 作为扫描视图
MobileScanner(
controller: controller.scannerController,
onDetect: controller.onDetect,
// 您可以自定义扫描框的样式
scanWindow: Rect.fromCenter(
center: Offset(
MediaQuery.of(context).size.width / 2,
MediaQuery.of(context).size.height / 2 - 50,
),
width: 250,
height: 250,
),
actions: [
TextButton(
onPressed: controller.requestPhotoPermission,
child: const Text(
'相册',
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
],
),
body: Stack(
children: <Widget>[
_buildQrView(context),
Positioned(
bottom: 80.h,
left: 0,
right: 0,
child: _buildControlButtons(),
),
],
),
);
},
// 扫描动画和覆盖层
_buildScannerOverlay(context),
// 底部的功能按钮
Positioned(bottom: 80, left: 0, right: 0, child: _buildActionButtons()),
],
),
);
}
/// 构建二维码扫描视图(带动画
Widget _buildQrView(BuildContext context) {
// 定义扫描区域的大小
var scanArea = (MediaQuery.of(context).size.width < 400 ||
MediaQuery.of(context).size.height < 400)
? 250.0
: 300.0;
/// 构建扫描区域的覆盖层和动画
Widget _buildScannerOverlay(BuildContext context) {
// 模拟扫描框的位置和大小
const double scanAreaSize = 250.0;
return Stack(
alignment: Alignment.center,
children: <Widget>[
// 底层是相机视图和半透明遮罩
QRView(
key: controller.qrKey,
onQRViewCreated: controller.onQRViewCreated,
overlay: QrScannerOverlayShape(
borderColor: Colors.blueAccent,
borderRadius: 10,
borderLength: 30,
borderWidth: 10,
cutOutSize: scanArea,
),
),
// 上层是扫描动画
AnimatedBuilder(
animation: controller.scanAnimation,
builder: (context, child) {
return Positioned(
// 计算扫描框的顶部位置,以便动画从顶部开始
top: (MediaQuery.of(context).size.height - scanArea) / 2,
child: Transform.translate(
offset: Offset(0, controller.scanAnimation.value * scanArea),
children: [
// 半透明的覆盖层
ColorFiltered(
colorFilter: ColorFilter.mode(Colors.black.withOpacity(0.5), BlendMode.srcOut),
child: Stack(
children: [
Container(decoration: const BoxDecoration(color: Colors.transparent)),
Align(
alignment: Alignment.center,
child: Container(
width: scanArea,
height: 2, // 扫描线的高度
margin: const EdgeInsets.only(bottom: 100), // 微调位置
width: scanAreaSize,
height: scanAreaSize,
decoration: BoxDecoration(
color: Colors.blueAccent,
boxShadow: [
BoxShadow(
color: Colors.blueAccent.withOpacity(0.7),
blurRadius: 8,
spreadRadius: 2,
),
],
color: Colors.black,
borderRadius: BorderRadius.circular(12),
),
),
),
);
},
],
),
),
// 扫描动画
Align(
alignment: Alignment.center,
child: Container(
margin: const EdgeInsets.only(bottom: 100),
width: scanAreaSize,
height: scanAreaSize,
child: AnimatedBuilder(
animation: controller.scanAnimation,
builder: (context, child) {
return CustomPaint(
painter: ScannerAnimationPainter(
controller.scanAnimation.value,
AppTheme.themeColor,
),
);
},
),
),
),
],
);
}
/// 构建底部的控制按钮
Widget _buildControlButtons() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'将二维码/条形码放入框内,即可自动扫描',
style: TextStyle(color: Colors.white, fontSize: 14),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
// 闪光灯按钮
_buildIconButton(
onPressed: controller.toggleFlash,
//闪光灯状态的变化
child: Obx(() => Icon(
controller.isFlashOn.value ? Icons.flash_on : Icons.flash_off,
color: Colors.white,
size: 28,
)),
),
// 翻转相机按钮
_buildIconButton(
onPressed: controller.flipCamera,
child: const Icon(
Icons.flip_camera_ios,
color: Colors.white,
size: 28,
/// 构建底部的功能按钮(闪光灯、相册)
Widget _buildActionButtons() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'将二维码/条形码放入框内,即可自动扫描',
style: TextStyle(color: Colors.white, fontSize: 14),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
// 闪光灯按钮
_buildIconButton(
onPressed: controller.toggleFlash,
//闪光灯状态的变化
child: Obx(
() => IconButton(
icon: Icon(
controller.isFlashOn.value ? Icons.flash_on : Icons.flash_off,
color: Colors.white,
size: 28,
),
onPressed: controller.toggleFlash,
),
),
),
),
],
),
],
);
}
// 翻转相机按钮
_buildIconButton(
onPressed: controller.flipCamera,
child: const Icon(
Icons.flip_camera_ios,
color: Colors.white,
size: 28,
),
),
],
),
],
);
}
Widget _buildIconButton({required VoidCallback onPressed, required Widget child}) {
return Container(
@@ -158,3 +165,71 @@ class QrCodePage extends GetView<QrCodeController> {
);
}
}
/// 扫描动画的绘制器
class ScannerAnimationPainter extends CustomPainter {
final double value;
final Color borderColor;
ScannerAnimationPainter(this.value, this.borderColor);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = borderColor
..strokeWidth = 3
..style = PaintingStyle.stroke;
final cornerLength = 20.0;
// 绘制四个角的边框
// Top-left
canvas.drawPath(
Path()
..moveTo(0, cornerLength)
..lineTo(0, 0)
..lineTo(cornerLength, 0),
paint,
);
// Top-right
canvas.drawPath(
Path()
..moveTo(size.width - cornerLength, 0)
..lineTo(size.width, 0)
..lineTo(size.width, cornerLength),
paint,
);
// Bottom-left
canvas.drawPath(
Path()
..moveTo(0, size.height - cornerLength)
..lineTo(0, size.height)
..lineTo(cornerLength, size.height),
paint,
);
// Bottom-right
canvas.drawPath(
Path()
..moveTo(size.width - cornerLength, size.height)
..lineTo(size.width, size.height)
..lineTo(size.width, size.height - cornerLength),
paint,
);
// 绘制扫描线
final linePaint = Paint()
..color = borderColor.withOpacity(0.8)
..strokeWidth = 2
..shader = LinearGradient(
colors: [borderColor.withOpacity(0), borderColor, borderColor.withOpacity(0)],
stops: const [0.0, 0.5, 1.0],
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
final y = size.height * value;
canvas.drawLine(Offset(0, y), Offset(size.width, y), linePaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}