样式调整
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart' as dio;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:getx_scaffold/getx_scaffold.dart';
|
||||
@@ -6,243 +7,156 @@ 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:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class QrCodeController extends GetxController
|
||||
with BaseControllerMixin, GetSingleTickerProviderStateMixin {
|
||||
class QrCodeController extends GetxController with BaseControllerMixin {
|
||||
@override
|
||||
String get builderId => 'qrcode';
|
||||
|
||||
// --- Animation ---
|
||||
late final AnimationController animationController;
|
||||
late final Animation<double> scanAnimation;
|
||||
|
||||
// --- 使用 MobileScanner 的控制器 ---
|
||||
final MobileScannerController scannerController = MobileScannerController();
|
||||
|
||||
final RxBool isFlashOn = false.obs;
|
||||
final RxBool isProcessingResult = false.obs;
|
||||
|
||||
final RxBool hasPermission = false.obs;
|
||||
final RxBool hasCameraPermission = false.obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
requestPermission();
|
||||
|
||||
animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 2500),
|
||||
vsync: this,
|
||||
);
|
||||
scanAnimation =
|
||||
Tween<double>(begin: 0, end: 1).animate(animationController);
|
||||
animationController.repeat(reverse: false);
|
||||
_checkPermission();
|
||||
}
|
||||
|
||||
/// MobileScanner 的 onDetect 回调方法
|
||||
void onDetect(BarcodeCapture capture) {
|
||||
if (isProcessingResult.value) return;
|
||||
|
||||
final Barcode? barcode = capture.barcodes.firstOrNull;
|
||||
if (barcode?.rawValue != null && barcode!.rawValue!.isNotEmpty) {
|
||||
isProcessingResult.value = true;
|
||||
scannerController.stop();
|
||||
animationController.stop();
|
||||
renderResult(barcode.rawValue!);
|
||||
}
|
||||
/// 检查权限
|
||||
void _checkPermission() async {
|
||||
var status = await Permission.camera.status;
|
||||
hasCameraPermission.value = status.isGranted;
|
||||
}
|
||||
|
||||
/// 恢复扫描状态
|
||||
void resumeScanner() {
|
||||
isProcessingResult.value = false;
|
||||
try {
|
||||
scannerController.start();
|
||||
animationController.repeat(reverse: false);
|
||||
} catch (e) {
|
||||
print("无法重启相机: $e");
|
||||
}
|
||||
}
|
||||
|
||||
/// 从相册选择图片并扫描二维码
|
||||
void scanFromGallery() async {
|
||||
try {
|
||||
final XFile? imageFile =
|
||||
await ImagePicker().pickImage(source: ImageSource.gallery);
|
||||
if (imageFile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
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, stackTrace) {
|
||||
dismissLoading();
|
||||
showErrorToast('从相册选择失败,请稍后重试');
|
||||
print("scanFromGallery Error: $e\n$stackTrace");
|
||||
resumeScanner();
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换闪光灯
|
||||
void toggleFlash() async {
|
||||
try {
|
||||
await scannerController.toggleTorch();
|
||||
final currentTorchState = scannerController.value.torchState;
|
||||
isFlashOn.value = currentTorchState == TorchState.on;
|
||||
} catch (e) {
|
||||
print("切换闪光灯失败: $e");
|
||||
showErrorToast("无法打开闪光灯");
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻转相机
|
||||
void flipCamera() async {
|
||||
await scannerController.switchCamera();
|
||||
}
|
||||
|
||||
/// 请求相机权限
|
||||
void requestPermission() async {
|
||||
/// 1. 拍照并识别车牌流程
|
||||
void takePhotoAndRecognize() async {
|
||||
var status = await Permission.camera.request();
|
||||
|
||||
hasPermission.value = status.isGranted;
|
||||
|
||||
if (!status.isGranted) {
|
||||
if (status.isPermanentlyDenied) {
|
||||
showErrorToast('相机权限已被永久拒绝,请到系统设置中开启');
|
||||
// 延迟一会再引导用户去设置
|
||||
Future.delayed(const Duration(seconds: 2), () => openAppSettings());
|
||||
} else {
|
||||
showErrorToast('请授予相机权限以使用扫描功能');
|
||||
}
|
||||
if (status.isPermanentlyDenied) openAppSettings();
|
||||
showErrorToast("需要相机权限才能拍照识别");
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
final XFile? photo = await ImagePicker().pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 80, // 压缩图片质量以加快上传
|
||||
);
|
||||
if (photo == null) return;
|
||||
|
||||
void requestPhotoPermission() async {
|
||||
var status = await Permission.photos.request();
|
||||
if (status.isGranted) {
|
||||
scanFromGallery();
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
openAppSettings();
|
||||
} else {
|
||||
showErrorToast('需要相册权限才能从相册中选择图片');
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理扫描结果
|
||||
void renderResult(String resultStr, {plateNumber}) async {
|
||||
showLoading("正在获取车辆信息...");
|
||||
try {
|
||||
final Map<String, dynamic> requestData = {
|
||||
"code": resultStr,
|
||||
"phone": StorageService.to.phone,
|
||||
};
|
||||
if (plateNumber != null && plateNumber.isNotEmpty) {
|
||||
requestData['plateNumber'] = plateNumber;
|
||||
}
|
||||
var responseData = await HttpService.to.post(
|
||||
"appointment/truck/bindTruck",
|
||||
data: requestData,
|
||||
);
|
||||
|
||||
if (responseData == null) {
|
||||
dismissLoading();
|
||||
showToast('无法获取车辆信息,请检查网络或稍后重试');
|
||||
resumeScanner();
|
||||
return;
|
||||
}
|
||||
var result = BaseModel.fromJson(responseData.data);
|
||||
|
||||
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 (_) {
|
||||
showErrorToast("网络请求失败,请稍后重试");
|
||||
resumeScanner();
|
||||
} catch (e, _) {
|
||||
showErrorToast("处理失败,请稍后重舍");
|
||||
resumeScanner();
|
||||
} finally {
|
||||
if (Get.isDialogOpen ?? false) {
|
||||
dismissLoading();
|
||||
// 1.1 上传文件
|
||||
String? imageUrl = await uploadFile(photo.path);
|
||||
if (imageUrl != null) {
|
||||
// 1.2 获取车牌号
|
||||
String? plateNumber = await getPlateNumber(imageUrl);
|
||||
if (plateNumber != null) {
|
||||
// 1.3 弹窗确认
|
||||
manualInputBind(plateNumber, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示绑定确认对话框
|
||||
void showBindDialog(String resultStr) {
|
||||
final TextEditingController plateNumberController = TextEditingController();
|
||||
// 使用 showConfirmDialog,它有 onCancel 回调
|
||||
/// 手动输入车牌绑定
|
||||
void manualInputBind(String plateNumber, int source) {
|
||||
final TextEditingController controller = TextEditingController(
|
||||
text: plateNumber.toUpperCase() ?? '',
|
||||
);
|
||||
|
||||
DialogX.to.showConfirmDialog(
|
||||
title: '请输入车牌号',
|
||||
barrierDismissible: false,
|
||||
content: TextField(
|
||||
controller: plateNumberController,
|
||||
autofocus: false,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '请输入完整的车牌号',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12.0),
|
||||
title: source == 0 ? "识别结果" : '手动输入车牌',
|
||||
content: SizedBox(
|
||||
height: 40.h,
|
||||
child: TextField(
|
||||
textAlign: TextAlign.start,
|
||||
controller: controller,
|
||||
autofocus: plateNumber.isEmpty,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: const InputDecoration(
|
||||
contentPadding: EdgeInsets.only(left: 5),
|
||||
hintText: '请输入完整的车牌号',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
confirmText: '确认绑定',
|
||||
cancelText: '取消', // showConfirmDialog 有 cancelText
|
||||
onConfirm: () {
|
||||
final String plateNumber = plateNumberController.text.trim();
|
||||
if (plateNumber.isEmpty) {
|
||||
showToast("请输入车牌号");
|
||||
// 返回 false 可以阻止弹窗关闭,让用户继续输入
|
||||
return false;
|
||||
}
|
||||
renderResult(resultStr, plateNumber: plateNumber);
|
||||
//关闭弹窗
|
||||
return true;
|
||||
},
|
||||
cancelText: source == 0 ? "重新拍摄" : '取消',
|
||||
onCancel: () {
|
||||
// 如果用户点击取消,恢复扫描
|
||||
resumeScanner();
|
||||
if (source == 0) {
|
||||
takePhotoAndRecognize();
|
||||
}
|
||||
},
|
||||
onConfirm: () {
|
||||
final plate = controller.text.trim().toUpperCase();
|
||||
if (plate.isNotEmpty) {
|
||||
bindVehicleByPlate(plate);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
scannerController.dispose();
|
||||
animationController.dispose();
|
||||
super.onClose();
|
||||
/// 上传图片
|
||||
Future<String?> uploadFile(String filePath) async {
|
||||
showLoading("正在上传图片...");
|
||||
try {
|
||||
dio.FormData formData = dio.FormData.fromMap({
|
||||
'file': await dio.MultipartFile.fromFile(filePath, filename: 'ocr_plate.jpg'),
|
||||
});
|
||||
|
||||
var response = await HttpService.to.post("appointment/ocr/upload", data: formData);
|
||||
if (response != null) {
|
||||
final result = BaseModel.fromJson(response.data);
|
||||
if (result.code == 0) return result.data.toString();
|
||||
showErrorToast(result.error);
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast("图片上传失败");
|
||||
} finally {
|
||||
dismissLoading();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// OCR 识别
|
||||
Future<String?> getPlateNumber(String imageUrl) async {
|
||||
showLoading("正在识别车牌...");
|
||||
try {
|
||||
var response = await HttpService.to.get(
|
||||
"appointment/ocr/getPlateNumber",
|
||||
params: {'imageUrl': imageUrl},
|
||||
);
|
||||
if (response != null) {
|
||||
final result = BaseModel.fromJson(response.data);
|
||||
if (result.code == 0) return result.data.toString();
|
||||
showErrorToast(result.error);
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast("车牌识别失败");
|
||||
} finally {
|
||||
dismissLoading();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 核心绑定方法
|
||||
void bindVehicleByPlate(String plateNumber) async {
|
||||
showLoading("正在绑定车辆...");
|
||||
try {
|
||||
var responseData = await HttpService.to.post(
|
||||
"appointment/truck/bindOcrTruck",
|
||||
data: {"plateNumber": plateNumber, "phone": StorageService.to.phone},
|
||||
);
|
||||
|
||||
var result = BaseModel.fromJson(responseData?.data);
|
||||
if (result.code == 0 && result.data != null) {
|
||||
await StorageService.to.saveVehicleInfo(VehicleInfo.fromJson(result.data));
|
||||
dismissLoading();
|
||||
showSuccessToast("绑定成功");
|
||||
Get.back(result: true);
|
||||
} else {
|
||||
showErrorToast(result.error);
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast("绑定失败,请检查网络");
|
||||
} finally {
|
||||
dismissLoading();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user