Files
ln-ios/ln_jq_app/lib/pages/b_page/site/controller.dart
2026-03-04 13:38:26 +08:00

1429 lines
50 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 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_pdfview/flutter_pdfview.dart';
import 'package:getx_scaffold/getx_scaffold.dart' as dio;
import 'package:getx_scaffold/getx_scaffold.dart';
import 'package:image_picker/image_picker.dart';
import 'package:ln_jq_app/common/model/base_model.dart';
import 'package:ln_jq_app/common/styles/theme.dart';
import 'package:ln_jq_app/storage_service.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:saver_gallery/saver_gallery.dart';
enum ReservationStatus {
pending, // 待处理 ( addStatus: 0)
completed, // 完成 ( addStatus: 1)
rejected, // 拒绝 ( -1)
unadded, // 未加 ( 2)
cancel, // 取消预约
unknown, // 未知状态
}
class ReservationModel {
final String id;
final String stationId;
final String plateNumber;
String amount;
final String time;
final String contactPerson;
final String contactPhone;
ReservationStatus status; // 状态是可变的
final String contacts;
final String phone;
final String rejectReason;
final String stationName;
final String startTime;
final String endTime;
final String date;
final String hydAmount;
final String state;
final String stateName;
final String addStatus;
final String addStatusName;
bool hasEdit;
final String isEdit; // "1" 表示可以修改信息
final String gunNumber;
// 新增附件相关字段
final int isTruckAttachment; // 1为有证件数据 0为缺少
final bool hasDrivingAttachment; // 是否有行驶证
final bool hasHydrogenationAttachment; // 是否有加氢证
final List<String> drivingAttachments; // 行驶证图片列表
final List<String> hydrogenationAttachments; // 加氢证图片列表
ReservationModel({
required this.id,
required this.stationId,
required this.plateNumber,
required this.amount,
required this.time,
required this.contactPerson,
required this.contactPhone,
required this.hasEdit,
this.status = ReservationStatus.pending,
required this.contacts,
required this.phone,
required this.stationName,
required this.startTime,
required this.endTime,
required this.date,
required this.hydAmount,
required this.state,
required this.stateName,
required this.addStatus,
required this.addStatusName,
required this.rejectReason,
required this.isTruckAttachment,
required this.hasDrivingAttachment,
required this.hasHydrogenationAttachment,
required this.isEdit,
required this.drivingAttachments,
required this.hydrogenationAttachments,
required this.gunNumber,
});
/// 工厂构造函数用于从JSON创建ReservationModel实例
factory ReservationModel.fromJson(Map<String, dynamic> json) {
// 1完成 2未加 -1拒绝 0是待加氢
ReservationStatus currentStatus;
int statusFromServer = json['addStatus'] as int? ?? 0;
int state = json['state'] as int? ?? 0;
if (state == -1) {
currentStatus = ReservationStatus.rejected;
} else {
switch (statusFromServer) {
case 0:
currentStatus = ReservationStatus.pending;
break;
case 1:
currentStatus = ReservationStatus.completed;
break;
case 2:
currentStatus = ReservationStatus.unadded;
break;
case 6:
currentStatus = ReservationStatus.cancel;
break;
default:
currentStatus = ReservationStatus.unknown;
}
}
// 格式化时间显示
String startTimeStr = json['startTime']?.toString() ?? '';
String endTimeStr = json['endTime']?.toString() ?? '';
String dateStr = json['date']?.toString() ?? '';
String timeRange =
(startTimeStr.isNotEmpty && endTimeStr.isNotEmpty && dateStr.isNotEmpty)
? '$dateStr ${startTimeStr.substring(11, 16)}-${endTimeStr.substring(11, 16)}' // 截取 HH:mm
: '时间未定';
// 解析附件信息
Map<String, dynamic> attachmentVo = json['truckAttachmentVo'] ?? {};
int isTruckAttachment = attachmentVo['isTruckAttachment'] as int? ?? 0;
List drivingList = attachmentVo['drivingAttachment'] ?? [];
List hydrogenationList = attachmentVo['hydrogenationAttachment'] ?? [];
return ReservationModel(
// 原始字段用于UI兼容
id: json['id']?.toString() ?? '',
stationId: json['stationId']?.toString() ?? '',
plateNumber: json['plateNumber']?.toString() ?? '---',
amount: '${json['hydAmount']?.toString() ?? '0'}',
time: timeRange,
contactPerson: json['contacts']?.toString() ?? '',
contactPhone: json['phone']?.toString() ?? '',
status: currentStatus,
// 新增的完整字段
contacts: json['contacts']?.toString() ?? '',
phone: json['phone']?.toString() ?? '',
stationName: json['stationName']?.toString() ?? '',
startTime: startTimeStr,
endTime: endTimeStr,
date: dateStr,
hydAmount: json['hydAmount']?.toString() ?? '0',
state: json['state']?.toString() ?? '',
addStatus: statusFromServer.toString(),
addStatusName: json['addStatusName']?.toString() ?? '',
stateName: json['stateName']?.toString() ?? '',
rejectReason: json['rejectReason']?.toString() ?? '',
hasEdit: true,
isTruckAttachment: isTruckAttachment,
hasDrivingAttachment: drivingList.isNotEmpty,
hasHydrogenationAttachment: hydrogenationList.isNotEmpty,
isEdit: json['isEdit']?.toString() ?? '0',
drivingAttachments: drivingList.map((e) => e.toString()).toList(),
hydrogenationAttachments: hydrogenationList.map((e) => e.toString()).toList(),
gunNumber: json['gunNumber']?.toString() ?? '',
);
}
}
class SiteController extends GetxController with BaseControllerMixin {
@override
String get builderId => 'site';
SiteController();
/// 状态变量:是否有预约数据
bool hasReservationData = false;
// 新增预约数据列表
List<ReservationModel> reservationList = [];
Timer? _refreshTimer;
final TextEditingController searchController = TextEditingController();
bool isNotice = false;
final RefreshController refreshController = RefreshController(initialRefresh: false);
// 加氢枪列表
final RxList<String> gasGunList = <String>[].obs;
final RxMap<String, Map<String, dynamic>> gasGunMap =
<String, Map<String, dynamic>>{}.obs;
@override
bool get listenLifecycleEvent => true;
@override
void onInit() {
super.onInit();
renderData();
msgNotice();
startAutoRefresh();
fetchGasGunList();
}
@override
void onPaused() {
stopAutoRefresh();
super.onPaused();
}
@override
void onClose() {
stopAutoRefresh();
searchController.dispose();
super.onClose();
}
Future<void> msgNotice() async {
final Map<String, dynamic> requestData = {
'appFlag': 1,
'isRead': 1,
'pageNum': 1,
'pageSize': 5,
};
final response = await HttpService.to.get(
'appointment/unread_notice/page',
params: requestData,
);
if (response != null) {
final result = BaseModel.fromJson(response.data);
if (result.code == 0 && result.data != null) {
String total = result.data["total"].toString();
isNotice = int.parse(total) > 0;
updateUi();
}
}
}
/// 创建一个每5分钟执行一次的周期性定时器
void startAutoRefresh() {
stopAutoRefresh();
_refreshTimer = Timer.periodic(const Duration(minutes: 5), (timer) {
renderData();
});
}
void onRefresh() => renderData(isRefresh: true);
///停止定时器
void stopAutoRefresh() {
_refreshTimer?.cancel();
_refreshTimer = null;
}
/// 获取加氢枪列表
Future<void> fetchGasGunList() async {
try {
var response = await HttpService.to.get(
'appointment/station/getGasGunList?hydrogenId=${StorageService.to.userId}',
);
if (response != null && response.data != null) {
final result = BaseModel<dynamic>.fromJson(response.data);
if (result.code == 0 && result.data != null) {
List dataList = result.data as List;
gasGunList.clear();
gasGunMap.clear();
for (var item in dataList) {
String name = item['deviceName'].toString();
// 将名称加入列表供 Dropdown/Picker 使用
gasGunList.add(name);
// 将完整对象存入 Map方便后续通过 name 获取 sign
gasGunMap[name] = Map<String, dynamic>.from(item);
}
}
}
} catch (e) {
Logger.d("获取加氢枪列表失败: $e");
}
}
/// 获取预约数据的方法
Future<void> fetchReservationData() async {
showLoading("加载中");
final String searchText = searchController.text.trim();
try {
var response = await HttpService.to.post(
"appointment/orderAddHyd/sitOrderPage",
data: {
'stationName': name, // 使用从 renderData 中获取到的 name
'pageNum': 1,
'pageSize': 50, // 暂时不考虑分页一次获取30条
'keyword': searchText, // 加氢站名称、手机号
'stationId': StorageService.to.userId,
},
);
// 安全校验
if (response == null || response.data == null) {
showToast('暂时无法获取预约数据');
hasReservationData = false;
reservationList = [];
dismissLoading();
return;
}
final baseModel = BaseModel<dynamic>.fromJson(response.data);
if (baseModel.code == 0 && baseModel.data != null) {
// 【核心修改】处理接口返回的列表数据
final dataMap = baseModel.data as Map<String, dynamic>;
final List<dynamic> listFromServer = dataMap['records'] ?? [];
// 使用 .map() 遍历列表,将每个 item 转换为一个 ReservationModel 对象
reservationList = listFromServer.map((item) {
return ReservationModel.fromJson(item as Map<String, dynamic>);
}).toList();
// 根据列表是否为空来更新 hasReservationData 状态
hasReservationData = reservationList.isNotEmpty;
} else {
// 接口返回业务错误
showToast(baseModel.message);
hasReservationData = false;
reservationList = []; // 清空列表
}
} catch (e) {
// 捕获网络或解析异常
showToast('获取预约数据失败');
hasReservationData = false;
reservationList = []; // 清空列表
} finally {
// 无论成功失败最后都要关闭加载动画并更新UI
dismissLoading();
updateUi();
}
}
/// 确认预约弹窗
Future<void> confirmReservation(
String id, {
bool isEdit = false,
bool isAdd = false,
}) async {
ReservationModel item;
//处理是否是无预约车辆加氢数据
if (!isAdd) {
item = reservationList.firstWhere(
(item) => item.id == id,
orElse: () => throw Exception('Reservation not found'),
);
} else {
// 如果是无预约车辆加氢,创建一个临时 model
item = ReservationModel(
id: "",
stationId: StorageService.to.userId ?? "",
plateNumber: "---",
amount: "0.000",
time: "",
contactPerson: "",
contactPhone: "",
hasEdit: true,
contacts: "",
phone: "",
stationName: name,
startTime: "",
endTime: "",
date: "",
hydAmount: "0.000",
state: "",
stateName: "",
addStatus: "",
addStatusName: "",
rejectReason: "",
isTruckAttachment: 0,
hasDrivingAttachment: false,
hasHydrogenationAttachment: false,
isEdit: "0",
drivingAttachments: [],
hydrogenationAttachments: [], gunNumber: '',
);
}
//车牌输入
final TextEditingController plateController = TextEditingController(
text: item.plateNumber == "---" ? "" : item.plateNumber,
);
// 加氢量保留3位小数
double initialAmount = double.tryParse(item.hydAmount) ?? 0.0;
final TextEditingController amountController = TextEditingController(
text: initialAmount.toStringAsFixed(3),
);
//枪号回填
String initialGun = '';
if (item.gunNumber.isNotEmpty && gasGunList.contains(item.gunNumber)) {
initialGun = item.gunNumber; // 如果接口有返回且在列表中,则反显
} else if (gasGunList.isNotEmpty) {
initialGun = gasGunList.first; // 否则默认选第一个
}
final RxString selectedGun = initialGun.obs;
final RxBool isOfflineChecked = false.obs;
Get.dialog(
Dialog(
insetPadding: EdgeInsets.only(left: 20.w, right: 20.w),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isAdd ? "无预约车辆加氢" : (isEdit ? '修改加氢量' : '确认加氢状态'),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// 车牌号及标签
Row(
children: [
Container(
width: 80.w,
child: TextField(
enabled: !isEdit,
controller: plateController,
style: TextStyle(
color: const Color.fromRGBO(51, 51, 51, 1),
fontSize: 14.sp,
fontWeight: FontWeight.bold,
),
decoration: InputDecoration(
hintText: item.plateNumber == "---" ? '_ _ _ _ _ _' : '修正车牌',
hintStyle: TextStyle(
color: const Color.fromRGBO(51, 51, 51, 1),
fontSize: 13.sp,
),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
),
),
const SizedBox(width: 8),
isEdit
? SizedBox()
: GestureDetector(
onTap: () async {
String? temp = await takePhotoAndRecognize(true);
if (temp != null && temp.isNotEmpty) {
plateController.text = temp;
}
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Color.fromRGBO(232, 243, 255, 1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.plateNumber == "---" ? '车牌号识别' : '重新识别',
style: TextStyle(
color: Color.fromRGBO(22, 93, 255, 1),
fontSize: 13.sp,
fontWeight: FontWeight.bold,
),
),
),
),
SizedBox(width: 16.w),
if (item.plateNumber != "---" && item.hasDrivingAttachment)
buildInfoTag('行驶证', item.drivingAttachments),
if (item.plateNumber != "---" && item.hasHydrogenationAttachment)
buildInfoTag('加氢证', item.hydrogenationAttachments),
],
),
SizedBox(height: 6.h),
// 提示逻辑
if (isEdit)
Text(
'每个订单只能修改一次,请确认加氢量准确无误',
style: TextStyle(
color: Colors.red,
fontSize: 12.sp,
fontWeight: FontWeight.w400,
),
)
else if (item.plateNumber == "---" || item.isTruckAttachment == 0)
Row(
children: [
Expanded(
child: Text(
'车辆未上传加氢证,请完成线下登记',
style: TextStyle(
color: Colors.red,
fontSize: 12.sp,
fontWeight: FontWeight.w400,
),
),
),
Obx(
() => Checkbox(
value: isOfflineChecked.value,
onChanged: (v) => isOfflineChecked.value = v ?? false,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
activeColor: AppTheme.themeColor,
),
),
],
),
SizedBox(height: 6.h),
// 预定加氢量输入区
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF7F8FA),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'预定加氢量',
style: TextStyle(
color: Color.fromRGBO(51, 51, 51, 1),
fontSize: 14.sp,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
IntrinsicWidth(
child: TextField(
controller: amountController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
// 限制最多输入3位小数
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,3}'),
),
],
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Color(0xFF017143),
),
decoration: const InputDecoration(
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Color(0xFF017143)),
),
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Color(0xFF017143)),
),
isDense: true,
contentPadding: EdgeInsets.zero,
),
),
),
const Text(
' KG',
style: TextStyle(color: Colors.grey, fontSize: 14),
),
],
),
],
),
),
GestureDetector(
onTap: () async {
String? temp = await takePhotoAndRecognize(
false,
deviceName: selectedGun.value,
sign: getSignByDeviceName(selectedGun.value),
);
if (temp != null && temp.isNotEmpty) {
amountController.text = temp;
}
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: const Color(0xFF017143),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
Icon(
Icons.camera_alt_outlined,
color: Colors.white,
size: 18,
),
SizedBox(width: 4),
Text(
'识别',
style: TextStyle(color: Colors.white, fontSize: 14),
),
],
),
),
),
],
),
),
const SizedBox(height: 16),
// 加氢枪号选择
const Text('请选择加氢枪号', style: TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(height: 8),
Obx(
() => Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedGun.value.isEmpty ? null : selectedGun.value,
isExpanded: true,
hint: const Text('请选择加氢枪号'),
items: gasGunList.map((String gun) {
return DropdownMenuItem<String>(value: gun, child: Text(gun));
}).toList(),
onChanged: (v) => selectedGun.value = v ?? '',
),
),
),
),
const SizedBox(height: 24),
// 按钮
Row(
children: [
Expanded(
flex: 2,
child: ElevatedButton(
onPressed: () {
//加氢后 订单编辑
if (isEdit) {
final num addHydAmount =
num.tryParse(amountController.text) ?? 0;
upDataService(
id,
0,
1,
addHydAmount,
"",
item!,
gunNumber: selectedGun.value,
plateNumber: item.plateNumber,
isEdit: true,
);
return;
}
//订单确认
if (!isEdit &&
(item!.plateNumber == "---" ||
item.isTruckAttachment == 0) &&
!isOfflineChecked.value) {
showToast("车辆未上传加氢证 , 请确保线下登记后点击确认");
return;
}
if (selectedGun.value.isEmpty) {
showToast("请选择加氢枪号");
return;
}
//无预约订单
if (isAdd) {
final num addHydAmount =
num.tryParse(amountController.text) ?? 0;
upDataService(
id,
0,
1,
addHydAmount,
"",
item,
gunNumber: selectedGun.value,
plateNumber: plateController.text,
isAdd: true,
);
return;
}
//有预约订单确认
final num addHydAmount =
num.tryParse(amountController.text) ?? 0;
upDataService(
id,
0,
1,
addHydAmount,
"",
item,
gunNumber: selectedGun.value,
plateNumber: plateController.text,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF017143),
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 0,
),
child: Text(
isEdit ? '确认修改' : '确认加氢',
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: OutlinedButton(
onPressed: () {
if (!isEdit && !isAdd) {
upDataService(
id,
0,
2,
0,
"",
item!,
gunNumber: selectedGun.value,
plateNumber: plateController.text,
);
} else {
Get.back();
}
},
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
isEdit || isAdd ? '取消' : '未加氢',
style: const TextStyle(color: Colors.grey, fontSize: 16),
),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
const Expanded(
child: Divider(color: Color(0xFFEEEEEE), thickness: 1),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: GestureDetector(
onTap: () => Get.back(),
child: Text(
'暂不处理',
style: TextStyle(
color: Color.fromRGBO(16, 185, 129, 1),
fontSize: 14.sp,
),
),
),
),
const Expanded(
child: Divider(color: Color(0xFFEEEEEE), thickness: 1),
),
],
),
],
),
),
),
),
barrierDismissible: false,
);
}
/// 保存图片到相册
Future<void> saveFileToLocal(String url) async {
try {
// 权限请求
if (Platform.isAndroid) {
dio.PermissionStatus status;
final deviceInfo = await DeviceInfoPlugin().androidInfo;
final sdkInt = deviceInfo.version.sdkInt;
if (sdkInt <= 32) {
status = await Permission.storage.request();
} else {
status = await Permission.photos.request();
}
if (!status.isGranted) {
showErrorToast("请在系统设置中开启存储权限");
return;
}
} else {
var status = await Permission.photos.request();
if (!status.isGranted) {
showErrorToast("请在系统设置中开启存储权限");
return;
}
}
showLoading("正在保存...");
// 下载文件
var response = await Dio().get(
url,
options: Options(responseType: ResponseType.bytes),
);
final Uint8List bytes = Uint8List.fromList(response.data);
if (url.toLowerCase().endsWith('.pdf')) {
String? savePath;
if (Platform.isAndroid) {
final directory = Directory('/storage/emulated/0/Download');
if (!await directory.exists()) {
await directory.create(recursive: true);
}
final String fileName = "certificate_${DateTime.now().millisecondsSinceEpoch}.pdf";
savePath = "${directory.path}/$fileName";
} else {
// iOS: 保存到文档目录
final directory = await getApplicationDocumentsDirectory();
final String fileName = "certificate_${DateTime.now().millisecondsSinceEpoch}.pdf";
savePath = "${directory.path}/$fileName";
}
final File file = File(savePath);
await file.writeAsBytes(bytes);
dismissLoading();
showSuccessToast(Platform.isAndroid ? "PDF已保存至系统下载目录" : "PDF已保存请在'文件'App中查看");
} else {
// 保存图片到相册
final result = await SaverGallery.saveImage(
bytes,
quality: 100,
fileName: "certificate_${DateTime.now().millisecondsSinceEpoch}",
skipIfExists: false,
);
dismissLoading();
if (result.isSuccess) {
showSuccessToast("图片已保存至相册");
} else {
showErrorToast("保存失败");
}
}
} catch (e) {
dismissLoading();
showErrorToast("保存异常");
}
}
Widget buildInfoTag(String label, List<String> images) {
return GestureDetector(
onTap: () {
showImagePreview(images);
},
child: Container(
margin: const EdgeInsets.only(left: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFF2F3F5),
borderRadius: BorderRadius.circular(4),
),
child: Text(
label,
style: TextStyle(color: Color(0xFF999999), fontSize: 11.sp),
),
),
);
}
/// 显示图片预览弹窗
void showImagePreview(List<String> images) {
if (images.isEmpty) return;
final RxInt currentIndex = 0.obs;
final PageController pageController = PageController();
Get.dialog(
GestureDetector(
onTap: () => Get.back(),
child: Stack(
alignment: Alignment.center,
children: [
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 图片翻页
SizedBox(
height: Get.height * 0.5,
child: PhotoViewGallery.builder(
scrollPhysics: const BouncingScrollPhysics(),
builder: (BuildContext context, int index) {
final String url = images[index];
final bool isPdf = url.toLowerCase().endsWith('.pdf');
if (isPdf) {
return PhotoViewGalleryPageOptions.customChild(
child: GestureDetector(
onTap: (){
_showSaveMenu(url);
},
child: _buildPdfPreview(url),),
initialScale: PhotoViewComputedScale.contained,
heroAttributes: PhotoViewHeroAttributes(tag: url),
onTapDown: (context, details, controllerValue) {
_showSaveMenu(url);
},
);
}
return PhotoViewGalleryPageOptions(
imageProvider: NetworkImage(url),
initialScale: PhotoViewComputedScale.contained,
heroAttributes: PhotoViewHeroAttributes(tag: url),
onTapDown: (context, details, controllerValue) {
_showSaveMenu(url);
},
);
},
itemCount: images.length,
loadingBuilder: (context, event) => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
pageController: pageController,
onPageChanged: (index) => currentIndex.value = index,
),
),
SizedBox(height: 10.h),
// 页码指示器
Center(
child: Text(
"${currentIndex.value + 1} / ${images.length}",
style: const TextStyle(
color: Colors.white,
fontSize: 16,
decoration: TextDecoration.none,
),
),
),
],
),
),
// 关闭按钮
Positioned(
top: 150.h,
right: 20,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white, size: 30),
onPressed: () => Get.back(),
),
),
],
),
),
useSafeArea: false,
);
}
/// PDF 预览小部件
Widget _buildPdfPreview(String url) {
return FutureBuilder<String>(
future: _downloadPdf(url),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator(color: Colors.white));
}
if (snapshot.hasError || snapshot.data == null) {
return const Center(child: Text("PDF 加载失败", style: TextStyle(color: Colors.white)));
}
return PDFView(
filePath: snapshot.data!,
enableSwipe: false,
swipeHorizontal: false,
autoSpacing: false,
pageFling: false,
);
},
);
}
Future<String> _downloadPdf(String url) async {
final file = File('${(await getTemporaryDirectory()).path}/${url.hashCode}.pdf');
if (await file.exists()) return file.path;
var response = await Dio().get(url, options: Options(responseType: ResponseType.bytes));
await file.writeAsBytes(response.data);
return file.path;
}
void _showSaveMenu(String url) {
final bool isPdf = url.toLowerCase().endsWith('.pdf');
Get.bottomSheet(
Container(
color: Colors.white,
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.download),
title: Text(isPdf ? '保存 PDF 文件' : '保存图片到相册'),
onTap: () {
Get.back();
saveFileToLocal(url);
},
),
const Divider(height: 1),
ListTile(
title: const Text('取消', textAlign: TextAlign.center),
onTap: () => Get.back(),
),
],
),
),
),
);
}
Future<void> rejectReservation(String id) async {
final item = reservationList.firstWhere((item) => item.id == id);
final TextEditingController reasonController = TextEditingController();
final RxString selectedReason = ''.obs;
// 预设的拒绝原因列表
final List<String> presetReasons = ['车辆未到场', '司机联系不上', '设备故障无法加氢'];
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'拒绝预约',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Text(
'正在处理车牌号 ${item.plateNumber} 的预约',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16, color: AppTheme.themeColor),
),
const SizedBox(height: 24),
// 4. 预设原因选择区域
const Text('选择或填写拒绝原因:', style: TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
Obx(
() => Wrap(
// 使用 Wrap 自动换行
spacing: 8.0, // 水平间距
children: presetReasons.map((reason) {
final isSelected = selectedReason.value == reason;
return ChoiceChip(
label: Text(reason),
selected: isSelected,
onSelected: (selected) {
if (selected) {
selectedReason.value = reason;
reasonController.clear(); // 选择预设原因时,清空自定义输入
}
},
selectedColor: Get.theme.primaryColor.withOpacity(0.2),
labelStyle: TextStyle(
color: isSelected ? Get.theme.primaryColor : Colors.black,
),
);
}).toList(),
),
),
const SizedBox(height: 16),
// 5. 自定义原因输入框
TextField(
controller: reasonController,
maxLines: 2,
decoration: InputDecoration(
hintText: '输入其它原因',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
onChanged: (text) {
if (text.isNotEmpty) {
selectedReason.value = ''; // 输入自定义原因时,取消预设选择
}
},
),
const SizedBox(height: 24),
// 6. 按钮区域
Row(
children: [
Expanded(
child: TextButton(
onPressed: () {
// 获取最终的拒绝原因
String finalReason = reasonController.text.trim();
if (finalReason.isEmpty) {
finalReason = selectedReason.value;
}
if (finalReason.isEmpty) {
showToast('请选择或填写一个拒绝原因');
return;
}
upDataService(
id,
1,
-1,
0,
finalReason,
item,
plateNumber: item.plateNumber,
);
},
child: const Text('确认拒绝', style: TextStyle(color: Colors.red)),
),
),
const SizedBox(width: 16),
Expanded(
child: TextButton(
onPressed: () => Get.back(),
child: const Text('暂不处理', style: TextStyle(color: Colors.grey)),
),
),
],
),
],
),
),
),
),
barrierDismissible: false,
);
}
//addStatus 1完成 2未加 -1拒绝
void upDataService(
String id,
int status,
int addStatus,
num addHydAmount,
String rejectReason,
ReservationModel item, {
String? gunNumber,
String? plateNumber,
bool isEdit = false,
bool isAdd = false,
}) async {
showLoading("确认中");
try {
dio.Response? responseData;
if (isAdd) {
responseData = await HttpService.to.post(
'appointment/orderAddHyd/addOfflineOrder',
data: {
"addHydAmount": addHydAmount,
"plateNumber": plateNumber,
if (gunNumber != null && gunNumber.isNotEmpty) "gunNumber": gunNumber,
},
);
} else if (isEdit) {
responseData = await HttpService.to.post(
'appointment/orderAddHyd/modifyOrder',
data: {
'id': id,
"addHydAmount": addHydAmount,
"plateNumber": plateNumber,
if (gunNumber != null && gunNumber.isNotEmpty) "gunNumber": gunNumber,
},
);
} else if (addStatus == -1) {
responseData = await HttpService.to.post(
'appointment/orderAddHyd/rejectOrder',
data: {
'id': id,
'state': -1, //拒绝使用
"rejectReason": rejectReason,
"plateNumber": plateNumber,
if (gunNumber != null && gunNumber.isNotEmpty) "gunNumber": gunNumber,
},
);
} else {
responseData = await HttpService.to.post(
'appointment/orderAddHyd/completeOrder',
data: {
'id': id,
'addStatus': addStatus, //完成使用 完成1,未加2`
"addHydAmount": addHydAmount,
"plateNumber": plateNumber,
if (gunNumber != null && gunNumber.isNotEmpty) "gunNumber": gunNumber,
},
);
}
if (responseData == null || responseData.data == null) {
dismissLoading();
return;
}
var result = BaseModel.fromJson(responseData.data);
if (result.code == 0) {
showSuccessToast("操作成功");
} else {
showToast(result.message);
}
Get.back();
dismissLoading();
//1完成 2未加 -1拒绝
if (addStatus == 1) {
item.status = ReservationStatus.completed;
item.amount = "${addHydAmount}kg";
} else if (addStatus == -1) {
item.status = ReservationStatus.rejected;
} else if (addStatus == 2) {
item.status = ReservationStatus.unadded;
}
renderData();
} catch (e) {
dismissLoading();
}
}
//车牌&加氢量 识别
Future<String?> takePhotoAndRecognize(
bool isPlate, {
String deviceName = "",
String sign = "",
}) async {
var status = await Permission.camera.request();
if (!status.isGranted) {
if (status.isPermanentlyDenied) openAppSettings();
showErrorToast("需要相机权限才能拍照识别");
return "";
}
final XFile? photo = await ImagePicker().pickImage(
source: ImageSource.camera,
imageQuality: 80, // 压缩图片质量以加快上传
);
if (photo == null) {
return "";
}
//上传文件
String? imageUrl = await uploadFile(photo.path);
String? ocrStr = "";
if (imageUrl != null) {
// 获取车牌号
if (isPlate) {
ocrStr = await getPlateNumber(imageUrl);
} else {
ocrStr = await getHyd(imageUrl, deviceName, sign);
}
return ocrStr;
}
return "";
}
String getSignByDeviceName(String deviceName) {
return gasGunMap[deviceName]?['sign']?.toString() ?? '';
}
/// 上传图片
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;
}
//加氢量识别 加油枪列表接口返回的deviceName 加油枪列表接口返回的sign
Future<String?> getHyd(String imageUrl, String deviceName, String sign) async {
showLoading("正在识别加氢量...");
try {
var response = await HttpService.to.post(
"appointment/hyd-ocr/get-info",
data: {"url": imageUrl, "deviceName": deviceName, "sign": sign},
);
if (response != null) {
final result = BaseModel.fromJson(response.data);
if (result.code == 0) return result.data["mass"].toString();
showErrorToast(result.error);
}
} catch (e) {
showErrorToast("车牌识别失败");
} finally {
dismissLoading();
}
return null;
}
String leftHydrogen = "";
String orderAmount = "";
String completedAmount = "";
String name = "";
String orderTotalAmount = "";
String orderUnfinishedAmount = "";
Future<void> renderData({bool isRefresh = false}) async {
try {
var responseData = await HttpService.to.get(
'appointment/station/getStationInfoById?hydrogenId=${StorageService.to.userId}',
);
if (responseData == null || responseData.data == null) {
showToast('暂时无法获取站点信息');
return;
}
try {
var result = BaseModel.fromJson(responseData.data);
leftHydrogen = result.data["leftHydrogen"] ?? "";
orderAmount = result.data["orderAmount"].toString();
completedAmount = result.data["completedAmount"].toString();
name = result.data["name"].toString();
orderTotalAmount = result.data["orderTotalAmount"].toString();
orderUnfinishedAmount = result.data["orderUnfinishedAmount"].toString();
leftHydrogen = leftHydrogen.isEmpty ? "统计中" : leftHydrogen.toString();
orderTotalAmount = orderTotalAmount.isEmpty ? "统计中" : orderTotalAmount.toString();
orderUnfinishedAmount = orderUnfinishedAmount.isEmpty
? "统计中"
: orderUnfinishedAmount.toString();
} catch (e) {
showToast('数据异常');
}
} catch (e) {
} finally {
//加载列表数据
fetchReservationData();
if (isRefresh) {
refreshController.refreshCompleted();
}
}
}
}