Files
flutterApp/lib/features/home/presentation/widgets/map/testmap_pages.dart

2342 lines
97 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:collection';
import 'dart:convert';
import 'dart:math';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:geolocator/geolocator.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'package:maibu_satabot_v2/components/confrim_dialog.dart';
import 'package:maibu_satabot_v2/components/toast.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
import 'package:maibu_satabot_v2/core/di/injection.dart';
import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.dart';
import 'package:maibu_satabot_v2/core/network/protocol_decoder.dart';
import 'package:maibu_satabot_v2/features/devices/data/models/device_add_path_point_model.dart' as work_area_model;
import 'package:maibu_satabot_v2/features/devices/data/models/device_work_area_param_model.dart' as work_area_model;
import 'package:maibu_satabot_v2/features/devices/data/repositories/generate_path_repository_Impl.dart';
import 'package:maibu_satabot_v2/features/devices/domain/usecases/get_work_record_usecase.dart';
import 'package:maibu_satabot_v2/features/devices/domain/usecases/select_work_record_usecase.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_event.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_state.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_state.dart';
import 'package:maibu_satabot_v2/features/home/presentation/widgets/BottomDirectionLine.dart';
import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/enum.dart';
import 'package:maibu_satabot_v2/components/input_confirm_dialog.dart';
import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/tracepoint.dart';
import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/CenterLocation.dart';
import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/HeadingPointer.dart';
import 'package:maibu_satabot_v2/features/home/presentation/widgets/obsToast.dart';
import 'package:maibu_satabot_v2/features/home/presentation/widgets/startpoint_area.dart';
import 'package:image/image.dart' as _image; // 注意命名空间冲突,使用 as img
import 'package:maibu_satabot_v2/features/home/presentation/widgets/video.dart';
import 'dart:ui' as ui;
import '../path_list_pages.dart';
// 定义轨迹点类型(经纬度)
typedef PlotPoint = LatLng;
// 保持 PlotData 类不变
class PlotData {
final String id;
final String plotName;
final String imageUrl; // 图片URL(本地/assets/网络都可)
final String? jsonData; // 原始数据的JSON字符串(可选,便于调试或后续使用)
PlotData({required this.id, required this.plotName, required this.imageUrl, this.jsonData});
}
class PlotDataPath {
final dynamic? jsonData; // 原始数据的JSON字符串(可选,便于调试或后续使用)
PlotDataPath({this.jsonData});
}
class MapPageEnterprise extends StatefulWidget {
const MapPageEnterprise({Key? key}) : super(key: key);
@override
State<MapPageEnterprise> createState() => _MapPageEnterpriseState();
}
class _MapPageEnterpriseState extends State<MapPageEnterprise> {
final _dispatcher = sl<NetMessageDispatcher>();
late StreamSubscription<RawPacket> _sub;
bool _isRefreshing = false; // 新增:页面刷新状态标志
bool _isVideoDialogOpen = false; // 控制视频弹窗显示
String _videoStreamUrl = "";
Offset _videoPopupPos = const Offset(0, 100); // 弹窗初始位置
// 🔥 关键修复:添加截图全局Key
final GlobalKey _mapRepaintKey = GlobalKey();
// 4. 标记点集合(flutter_map需要Set类型)
Set<Marker> _markers = {};
final MapController _mapController = MapController();
// 初始化轨迹管理器(泛型指定为LatLng)
late final TracePoint<PlotPoint> _traceManager;
late String workMode = "弓字模式"; // 作业模式:默认值为"弓字模式"
WorkStatus _workStatus = WorkStatus.idle;
bool isStartWork = false; //是否开始作业
LatLng? _currentLatLng;
StreamSubscription<Position>? _positionSub;
bool _hasMovedOnce = false;
bool _isPanelOpen = false; // 控制面板显示/隐藏
bool _directionBoxOpen = false; //航线面板显示/隐藏
bool _saveBoxOpen = false; //保存按钮显示/隐藏
bool _isListBoxOpen = false; //列表面板显示/隐藏
// 新增:存储选中的地块(null表示未选中)
PlotData? _selectedPlot;
PlotDataPath? _selectedPlotPath; // 存储选中地块的路径数据(可选)
// 新增:控制底部作业面板显示
bool _isWorkPanelOpen = false;
bool _isWorkAreaCompleted = false;
RobotMode _currentRobotMode = RobotMode.point; //打点模式
AreaMode _currentAreaMode = AreaMode.work; //作业区域还是障碍物区域
WorkMode? _currentWorkMode = WorkMode.bow; // 当前作业模式
// 新增:存储转换后的path和outer坐标
List<LatLng> gcjPathPoints = [];
List<LatLng> gcjOuterPoints = [];
List<PlotPoint>? tracePoint = []; //从tracepoint中获取的经纬度坐标
List<PlotPoint>? gctracePoint = [];
// ========== 核心新增状态 ==========
final List<LatLng> _markedPoints = []; // 存储所有打点坐标
LatLng _mapCenter = const LatLng(39.9042, 116.4074);
double _headingAngle = 0.0; // 当前机器航向角(单位:度)
List<LatLng>? _currentTracePoints; //收到机器的点坐标
List typedPathList = <work_area_model.DeviceAddPathPointModel>[]; // 存储生成路径的坐标列表(已转换为LatLng)
// ========== 障碍物模式核心状态 ==========
List<List<LatLng>> _obstacleHoles = []; // 存储多组障碍物打点(嵌套数组:每组是一个空洞)
List<LatLng> _currentObstaclePoints = []; // 当前正在绘制的障碍物打点
bool _isObstacleEditing = false; // 是否处于障碍物编辑状态
//打点
double _workDistance = 0.0; // 作业行距(单位:米)
double _angle = 0.0; // 航线方向角(单位:度)
@override
void initState() {
super.initState();
_traceManager = TracePoint<PlotPoint>();
_initLocationEnterprise();
//_traceManager.onCurrentPointUpdated = (point) {
// setState(() {
// _currentTracePoints = _traceManager.getTracePoint();
// });
//};
//_traceManager.onCompletePointAdded = (point) {
// setState(() {
// _currentTracePoints = _traceManager.getTracePoint();
// });
//};
// 示例:切换到导航模式
_traceManager.setMode(TPMode.LOCATION);
// 监听地图移动事件,实时更新连线
_mapController.mapEventStream.listen((event) {
if (event is MapEventMove || event is MapEventMoveEnd) {
if (mounted && _mapController.camera != null) {
final originalCenter = _mapController.camera!.center;
final highPrecisionCenter = LatLng(
originalCenter.latitude, // 原始纬度(保留全部小数位)
originalCenter.longitude, // 原始经度(保留全部小数位)
);
setState(() {
_mapCenter = highPrecisionCenter;
});
}
}
});
}
// ========== 新增:计算坐标列表的边界范围 ==========
LatLngBounds? calculateBounds(List<LatLng> points) {
if (points.isEmpty) return null;
// 使用静态方法 fromPoints 创建边界(所有latlong2版本都支持)
return LatLngBounds.fromPoints(points);
}
// ========== 新增:移动地图到坐标中心 ==========
// ========== 移动地图到坐标中心(无需修改) ==========
void moveMapToPointsCenter(List<LatLng> points) {
if (points.isEmpty || !mounted) return;
// 计算边界
final bounds = calculateBounds(points);
if (bounds == null) return;
// 移动地图到边界中心(自动适配缩放级别)
_mapController.fitBounds(
bounds,
options: FitBoundsOptions(
padding: const EdgeInsets.all(50), // 边缘留白(避免内容贴边)
//maxZoom: 18, // 最大缩放级别(防止过度放大)
),
);
print('地图已移动到绘制内容中心,边界范围:$bounds');
}
// ========== 新增:安全转换任意类型为double ==========
double? safeToDouble(dynamic value) {
if (value == null) return null;
if (value is double) return value;
if (value is int) return value.toDouble();
if (value is String) {
return double.tryParse(value); // 字符串转数字(失败返回null)
}
return null;
}
bool _isValidLatLng(double lat, double lng) {
return lat != 0 && lng != 0 && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
}
//void _updateMarkers() {
// if (_currentLatLng == null) {
// _markers = {};
// return;
// }
// // 构建flutter_map的Marker
// _markers = {
// Marker(
// // 标记点坐标
// point: _currentLatLng!,
// // 标记点宽高
// width: 40,
// height: 40,
// // 标记点样式(替换为你的自定义样式,先试用默认容器验证)
// child: Container(
// decoration: BoxDecoration(
// color: Colors.red,
// borderRadius: BorderRadius.circular(20),
// border: Border.all(color: Colors.white, width: 2),
// ),
// child: const Center(child: Text('🚗', style: TextStyle(fontSize: 20))),
// // 如果你有自定义Painter,替换为:
// // child: CustomPaint(
// // size: const Size(40, 40),
// // painter: HeadingMarkerPainter(headingAngle: _headingAngle),
// // ),
// ),
// ),
// };
// // 触发UI刷新(flutter_map会自动更新标记点)
// if (mounted) {
// setState(() {});
// }
//}
void _onDeviceData(double lng, double lat, bool obfFlag, int headingStatus, controlMode) {
try {
// 1. 校验坐标有效性
if (!_isValidLatLng(lat, lng)) {
debugPrint('坐标无效:纬度=$lat, 经度=$lng,跳过处理');
return;
}
if (isStartWork && headingStatus == 0) {
ToastUtils.showWarn(context, '航向角未初始化');
return;
}
if (isStartWork && controlMode != "3") {
ToastUtils.showWarn(context, '请切换到远程模式');
}
//if (isStartWork && obfFlag) {
// ObsToastWidget.show(context: context, message: "小迈提醒您前方有障碍物哦!");
//} else {
// ObsToastWidget.dismiss();
//}
final wgsPoint = LatLng(lat, lng);
// 2. 转换为高德GCJ02坐标系
// 可选:更新航向角(如果有角度数据)
// _headingAngle = updatedState.status.heading ?? 0.0;
// 可选:插入队列
_traceManager.upsert(wgsPoint, TPAction.UPDATE);
tracePoint = _traceManager.getTracePoint();
gctracePoint = batchWgs84ToGcj02(tracePoint!);
debugPrint("$gctracePoint gctracePoint");
} catch (e) {
debugPrint('处理经纬度数据失败:$e');
}
}
void _showSavePlotDialog() {
showInputConfirmDialog(
context: context,
title: '保存地块', // 自定义标题
hintText: '请输入地块名称(如:北地块、一号田)', // 自定义输入提示
labelText: '地块名称', // 自定义输入框标签
confirmText: '保存', // 确认按钮文字
cancelText: '取消', // 取消按钮文字
// 输入校验器(可选)
inputValidator: (inputText) {
if (inputText.isEmpty) {
return '地块名称不能为空!';
}
if (inputText.length > 20) {
return '地块名称不能超过20个字符!';
}
return null; // 校验通过
},
onConfirm: (plotName) async {
// 1. 关闭输入弹窗,显示截图加载中
Navigator.pop(context);
showDialog(
context: context,
barrierDismissible: false, // 禁止点击外部关闭
builder: (ctx) => const Center(
child: SizedBox(width: 60, height: 60, child: CircularProgressIndicator(strokeWidth: 3, color: Colors.white)),
),
);
// 2. 执行截图(核心调用)
String? imgBase64 = await _captureMapToJpgBase64(quality: 80);
// 4. 处理截图结果并保存
if (mounted) {
if (imgBase64 != null) {
_savePlotData(plotName, imgBase64); // 传入 Base64
} else {
// 截图失败时,允许空图片保存(可根据业务调整为强制失败)
_savePlotData(plotName, null);
ToastUtils.showWarn(context, '地块保存成功,但地图截图生成失败!');
//ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('地块保存成功,但地图截图生成失败!'), backgroundColor: Colors.amber));
}
}
},
);
}
void _debugPrintMultipartRequest(http.MultipartRequest request) {
debugPrint('====================================');
debugPrint('📡 请求 URL: ${request.url}');
debugPrint('📡 请求方法: ${request.method}');
// 1. 打印请求头(包含自动生成的 boundary)
debugPrint('📡 请求头: ${request.headers}');
// 2. 打印普通字段 (虽然我们现在没有普通字段,但保留以防万一)
if (request.fields.isNotEmpty) {
debugPrint('📝 普通字段:');
request.fields.forEach((key, value) {
debugPrint(' - $key: $value');
});
} else {
debugPrint('📝 普通字段: 无');
}
// 3. 打印文件/Blob 字段 (核心:这里包含 file 和 workRecord)
debugPrint('📁 上传文件/Blob 数量: ${request.files.length}');
for (var file in request.files) {
debugPrint('------------------------------------');
debugPrint(' 字段名 (name): ${file.field}');
debugPrint(' 文件名 (filename): ${file.filename}');
debugPrint(' 内容类型 (contentType): ${file.contentType}');
// 特殊处理:如果是 workRecord,打印其内容
if (file.field == 'workRecord' && file is http.MultipartFile) {
// 将 ByteStream 转为 Uint8List 以查看内容
file.finalize().first.then((bytes) {
String content = utf8.decode(bytes);
// 格式化 JSON 打印,更易读
try {
dynamic jsonObj = jsonDecode(content);
debugPrint(' 内容 (content): ${const JsonEncoder.withIndent(' ').convert(jsonObj)}');
} catch (e) {
debugPrint(' 内容 (content): $content');
}
});
} else {
// 对于图片文件,打印大小即可,避免打印海量 Base64
file.finalize().first.then((bytes) {
debugPrint(' 文件大小: ${bytes.length} 字节 (约 ${(bytes.length / 1024).toStringAsFixed(1)} KB)');
});
}
}
debugPrint('====================================');
}
// 3. 保存地块数据的核心方法(这里写你的实际保存逻辑)
// 🔥 关键修改:增加 imgBase64 参数
// 🔥 新的保存方法,严格匹配后端结构
Future<void> _savePlotData(String plotName, String? imgBase64) async {
// 1. 获取用户ID
final userId = context.read<AppUserCubit>().state.user?.userId ?? "";
if (userId.isEmpty) {
ToastUtils.showError(context, '用户ID为空,无法保存!');
return;
}
// 2. 构造 SavePath 数据 (严格对应你的 JS 结构)
final Map<String, dynamic> savePath = {
//'img': imgBase64 ?? '', // 截图的 Base64 字符串
'name': plotName, // 地块名称
'path': _currentWorkMode == WorkMode.custom
? jsonEncode([]) // 自定义模式 path 为空数组
: jsonEncode(
typedPathList
.map(
(point) => {
// 路径点数组
'lon': point.longitude,
'lat': point.latitude,
},
)
.toList(),
),
'outer': jsonEncode(
gcjOuterPoints.map((point) {
final wgs84 = gcj02ToWgs84(point.latitude, point.longitude);
return {'lng': wgs84.longitude, 'lat': wgs84.latitude};
}).toList(),
),
'planModel': _currentWorkMode == WorkMode.bow ? 0 : 2, // 作业模式值
};
// 3. 构造 workRecord (包裹一层)
final Map<String, dynamic> workRecord = {
'workName': plotName,
'userId': userId,
'jsonData': jsonEncode(savePath), // 将 savePath 转为 JSON 字符串
};
final String workRecordJson = jsonEncode(workRecord);
final http.MultipartRequest request = http.MultipartRequest('POST', Uri.parse('https://serviceri.satabot.com/iot/workRecord/add'));
// 5. 处理文件:将 Base64 转为 MultipartFile (对应 JS 的 dataURLtoFile)
if (imgBase64 != null && imgBase64.isNotEmpty) {
// 移除 Base64 头部 (如果有的话)
String base64String = imgBase64;
if (base64String.startsWith('data:image/jpeg;base64,')) {
base64String = base64String.split(',').last;
}
Uint8List bytes = base64Decode(base64String);
// 添加文件 (对应 formData.append("file", file))
request.files.add(http.MultipartFile.fromBytes('file', bytes, filename: 'image.jpg', contentType: http.MediaType('image', 'jpeg')));
}
// 6. 添加 workRecord (对应 formData.append("workRecord", Blob))
request.files.add(
http.MultipartFile.fromString(
'workRecord', // 键名必须是 workRecord
workRecordJson,
filename: 'workRecord.json', // 后端可能需要这个文件名来识别
contentType: http.MediaType('application', 'json'), // 关键:指定 JSON 类型
),
);
//_debugPrintMultipartRequest(request);
// 7. 发送请求
try {
ToastUtils.showLoading(context, '正在保存地块「$plotName」...');
//ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('正在保存地块...'), backgroundColor: Colors.blue));
final http.StreamedResponse response = await request.send();
final String responseBody = await response.stream.bytesToString();
debugPrint('保存地块响应体: $responseBody');
if (response.statusCode == 200) {
ToastUtils.showSuccess(context, '地块「$plotName」保存成功!');
//ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('地块「$plotName」保存成功!'), backgroundColor: Colors.green));
// 保存成功
setState(() {
_markedPoints.clear(); // 清空打点
gcjPathPoints.clear(); // 清空路径
gcjOuterPoints.clear(); // 清空外边界
_isPanelOpen = false; // 关闭操作面板
_isWorkAreaCompleted = false; // 重置作业区域完成状态
});
} else {
throw Exception('服务器错误: ${response.statusCode}, $responseBody');
}
} catch (e) {
debugPrint('保存失败: $e');
ToastUtils.showError(context, '保存失败: ${e.toString()}');
//ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存失败: ${e.toString()}'), backgroundColor: Colors.red));
}
}
List<LatLng> _getPolygonPoints() {
if (_markedPoints.isEmpty || _mapController.camera == null) return [];
List<LatLng> polygonPoints = List.from(_markedPoints);
polygonPoints.add(_mapCenter);
return polygonPoints;
}
//void _undoLastPoint() {
// if (_markedPoints.isNotEmpty) {
// setState(() {
// _markedPoints.removeLast(); // 删除最后一个打点
// });
// debugPrint('撤回成功,剩余打点数量:${_markedPoints.length}');
// } else {
// debugPrint('无打点可撤回');
// // 可选:提示用户
// ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无打点可撤回')));
// }
//}
// ========== 统一撤回方法(适配完成后状态) ==========
void _undoAction() {
setState(() {
// 1. 作业区域模式
if (_currentAreaMode == AreaMode.work) {
if (_markedPoints.isNotEmpty) {
_markedPoints.removeLast();
// 作业点撤回后,若路径已生成,重新生成路径(保持同步)
if (_isWorkAreaCompleted && _markedPoints.length >= 3) {
_generatePath(showTips: false);
}
ToastUtils.showInfo(context, '已撤回作业区域最后一个点,剩余${_markedPoints.length}个点');
//ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已撤回作业区域最后一个点,剩余${_markedPoints.length}个点')));
} else {
ToastUtils.showInfo(context, '暂无作业区域点可撤回');
//ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无作业区域点可撤回')));
}
return;
}
// 2. 空洞模式(完成后优先撤回已完成的空洞)
if (_currentAreaMode == AreaMode.obstacle) {
if (_currentObstaclePoints.isNotEmpty) {
_currentObstaclePoints.removeLast();
// 如果当前空洞的点撤空了,退出编辑状态
if (_currentObstaclePoints.isEmpty) {
_isObstacleEditing = false;
}
ToastUtils.showInfo(context, '已撤回当前空洞最后一个点,剩余${_currentObstaclePoints.length}个点');
//ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已撤回当前空洞最后1个点,剩余${_currentObstaclePoints.length}个点')));
return; // 撤完点就结束,不执行后面的逻辑
}
// 第二步:当前空洞无点可撤 → 再撤回「上一个完整空洞」
if (_obstacleHoles.isNotEmpty) {
_obstacleHoles.removeLast();
// 撤完空洞后重新生成路径(排除该空洞)
if (_isWorkAreaCompleted) {
_generatePath(showTips: false);
}
ToastUtils.showInfo(context, '已撤回上一个完整空洞,剩余${_obstacleHoles.length}个空洞');
//ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已撤回上一个完整空洞,剩余${_obstacleHoles.length}个空洞')));
return;
}
// 第三步:无任何可撤内容
ToastUtils.showInfo(context, '暂无空洞可撤回');
//ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无空洞可撤回')));
}
});
}
// 兼容原有调用(可保留,也可直接用上面的_unifiedUndo)
void _undoLastPoint() => _undoAction();
void _undoObstaclePoint() => _undoAction();
// ========== 统一删除方法(适配完成后状态) ==========
void _deleteAllAction() {
setState(() {
// 1. 作业区域模式:清空作业点 + 重置路径
if (_currentAreaMode == AreaMode.work) {
if (_markedPoints.isEmpty) {
ToastUtils.showInfo(context, '暂无作业区域点可删除');
//ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无作业区域点可删除')));
return;
}
_markedPoints.clear();
gcjPathPoints.clear();
gcjOuterPoints.clear();
_isWorkAreaCompleted = false;
_saveBoxOpen = false;
typedPathList.clear();
ToastUtils.showSuccess(context, '已清空所有作业区域点和路径');
//ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已清空所有作业区域点和路径')));
}
// 2. 空洞模式:清空所有空洞 + 恢复UI + 重新生成路径
else if (_currentAreaMode == AreaMode.obstacle) {
if (_obstacleHoles.isEmpty && _currentObstaclePoints.isEmpty) {
ToastUtils.showInfo(context, '暂无空洞可删除');
return;
}
_obstacleHoles.clear();
_currentObstaclePoints.clear();
_isObstacleEditing = false;
// 清空后重新生成路径(无空洞)
if (_isWorkAreaCompleted) {
_generatePath(showTips: false);
}
ToastUtils.showSuccess(context, '已清空所有空洞');
}
});
}
// 兼容原有调用
void _deleteAllPoints() => _deleteAllAction();
void _clearAllObstacles() => _deleteAllAction();
// ========== 核心:清空所有打点和轨迹 ==========
//void _deleteAllPoints() {
// if (_markedPoints.isNotEmpty) {
// setState(() {
// _markedPoints.clear(); // 清空所有打点
// });
// debugPrint('删除成功,已清空所有打点和轨迹');
// // 可选:提示用户
// ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已清空所有打点和轨迹')));
// } else {
// debugPrint('无打点可删除');
// ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无打点可删除')));
// }
//}
/// ===============================
/// 企业级定位初始化(已修复坐标系)
Future<void> _initLocationEnterprise() async {
// 1. 设置轨迹模式为 LOCATION(核心:确保模式正确)
_traceManager.setMode(TPMode.LOCATION);
// 2. 清空旧的定位监听(如果有)
await _positionSub?.cancel();
_positionSub = null;
// 3. 初始化默认坐标(可选,避免地图空白)
setState(() {
_currentLatLng = const LatLng(39.9042, 116.4074); // 北京默认坐标
});
}
// ========== 新增:页面刷新初始化方法 ==========
Future<void> _handleRefresh() async {
// 防止重复刷新
if (_isRefreshing) return;
setState(() {
_isRefreshing = true; // 标记开始刷新
});
try {
// 1. 取消旧的定位监听
await _positionSub?.cancel();
// 2. 重置所有页面状态(恢复初始值)
setState(() {
_traceManager.setMode(TPMode.LOCATION);
// 清空打点和轨迹数据
_markedPoints.clear();
gcjPathPoints.clear();
gcjOuterPoints.clear();
_currentTracePoints = null;
_isWorkAreaCompleted = false;
_saveBoxOpen = false;
// 重置面板状态
_isPanelOpen = false;
_directionBoxOpen = false;
_isListBoxOpen = false;
_isWorkPanelOpen = false;
_obstacleHoles.clear();
_currentObstaclePoints.clear();
_isObstacleEditing = false;
// 重置选中状态
_selectedPlot = null;
_selectedPlotPath = null;
// 重置作业模式
_currentRobotMode = RobotMode.point;
_currentAreaMode = AreaMode.work;
_currentWorkMode = WorkMode.bow;
// 重置参数
_workDistance = 0.0;
_angle = 0.0;
_mapCenter = const LatLng(39.9042, 116.4074);
_hasMovedOnce = false;
_currentLatLng = null;
});
// 3. 重新初始化定位
await _initLocationEnterprise();
// 4. 重置轨迹管理器
_traceManager.reset();
// 5. 重新加载作业记录(保持数据最新)
final userId = context.read<AppUserCubit>().state.user?.userId ?? "";
if (userId.isNotEmpty) {
await context.read<DevicesCubit>().loadWorkRecords(userId);
}
// 6. 重置地图视角到初始位置
_mapController.move(const LatLng(39.9042, 116.4074), 17);
// 刷新成功提示
if (mounted) {
ToastUtils.showSuccess(context, '页面已刷新完成');
//ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('页面已刷新完成'), backgroundColor: Colors.green, duration: Duration(seconds: 2)));
}
} catch (e) {
// 错误处理
debugPrint('刷新失败:$e');
if (mounted) {
ToastUtils.showError(context, '刷新失败:${e.toString().substring(0, 50)}');
//ScaffoldMessenger.of(
// context,
//).showSnackBar(SnackBar(content: Text('刷新失败:${e.toString().substring(0, 50)}'), backgroundColor: Colors.red, duration: Duration(seconds: 3)));
}
} finally {
// 标记刷新结束
if (mounted) {
setState(() {
_isRefreshing = false;
});
}
}
}
/// ===============================
/// 更新位置
/// ===============================
void _updateLocation(LatLng latLng, {bool moveMap = false}) {
setState(() {
_currentLatLng = latLng;
});
if (moveMap) {
//_hasMovedOnce = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_hasMovedOnce = true;
_mapController.move(latLng, 17);
});
}
}
/// 手动回到当前位置
void _moveToCurrentLocation() {
if (_currentLatLng == null) return;
_mapController.move(_currentLatLng!, 17);
}
// ========== 障碍物模式:完成当前障碍物绘制 ==========
void _completeObstacle() {
if (_currentObstaclePoints.length < 3) {
ToastUtils.showError(context, '障碍物区域至少需要3个打点!');
return;
}
setState(() {
_obstacleHoles.add(List.from(_currentObstaclePoints)); // 保存当前障碍物组
_currentObstaclePoints.clear(); // 清空当前绘制的障碍物点
_isObstacleEditing = false; // 退出编辑状态
});
debugPrint('完成障碍物绘制,当前障碍物组数:${_obstacleHoles.length}');
ToastUtils.showSuccess(context, '已添加第${_obstacleHoles.length}个障碍物区域');
}
//void _undoObstaclePoint() {
// if (_currentObstaclePoints.isNotEmpty) {
// setState(() => _currentObstaclePoints.removeLast());
// debugPrint('撤回障碍物打点,剩余:${_currentObstaclePoints.length}个');
// } else if (_obstacleHoles.isNotEmpty) {
// // 无当前绘制点时,删除最后一组障碍物
// setState(() => _obstacleHoles.removeLast());
// debugPrint('删除最后一组障碍物,剩余组数:${_obstacleHoles.length}');
// } else {
// ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无障碍物打点可撤回')));
// }
//}
//// ========== 障碍物模式:清空所有障碍物 ==========
//void _clearAllObstacles() {
// setState(() {
// _obstacleHoles.clear();
// _currentObstaclePoints.clear();
// _isObstacleEditing = false;
// });
// debugPrint('已清空所有障碍物区域');
// ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已清空所有障碍物区域'), backgroundColor: Colors.amber));
//}
// ========== 核心方法:打点逻辑 ==========
void _addMarkedPoint() {
if (_currentAreaMode == AreaMode.work && _isWorkAreaCompleted) {
ToastUtils.showError(context, '作业区域已完成,无法继续添加打点!');
return;
}
setState(() {
if (_currentAreaMode == AreaMode.work) {
_markedPoints.add(_mapCenter);
debugPrint('新增作业区域打点:第${_markedPoints.length}个点,经纬度:${_mapCenter.latitude}, ${_mapCenter.longitude}');
} else {
// 障碍物模式:添加到当前障碍物打点
_currentObstaclePoints.add(_mapCenter);
_isObstacleEditing = true; // 标记进入障碍物编辑状态
debugPrint('新增障碍物打点:第${_currentObstaclePoints.length}个点,经纬度:${_mapCenter.latitude}, ${_mapCenter.longitude}');
}
});
debugPrint('$_markedPoints,新增打点:第${_markedPoints.length}个点,经纬度:${_mapCenter.latitude.toStringAsFixed(20)}, ${_mapCenter.longitude.toStringAsFixed(20)}');
}
// ========== 新增:返回上一级页面的方法 ==========
void _navigateBack() {
// 关闭当前页面,返回上一级
Navigator.of(context).pop();
}
@override
void dispose() {
_positionSub?.cancel();
_traceManager.reset();
_mapController.dispose();
_sub.cancel();
super.dispose();
}
void _handleVideo() {
setState(() {
_isVideoDialogOpen = true; // 打开视频弹窗
});
}
// 模拟添加完成点
void _addCompletePoint() {
final newPoint = LatLng(39.9042 + (DateTime.now().millisecond / 10000), 116.4074 + (DateTime.now().millisecond / 10000));
_traceManager.upsert(newPoint, TPAction.ADD);
}
// 模拟更新当前点
void _updateCurrentPoint() {
final newPoint = LatLng(39.9042 + (DateTime.now().millisecond / 10000), 116.4074 + (DateTime.now().millisecond / 10000));
_traceManager.upsert(newPoint);
}
// 构建视频弹窗
// 构建视频小弹窗(可拖拽)
// 构建视频小弹窗(可拖拽)
Widget _buildVideoPopup() {
return Positioned(
left: _videoPopupPos.dx,
top: _videoPopupPos.dy,
child: GestureDetector(
// 拖拽区域(整个弹窗可拖拽)
onPanUpdate: (details) {
setState(() {
_videoPopupPos = Offset(_videoPopupPos.dx + details.delta.dx, _videoPopupPos.dy + details.delta.dy);
});
},
child: WebRTCMapPlayer(
streamUrl: _videoStreamUrl,
width: 320,
height: 180,
showLeftPip: true,
showRightPip: true,
onClose: _closeVideoDialog, // 关闭回调
),
),
);
}
void _closeVideoDialog() {
setState(() {
_isVideoDialogOpen = false;
});
}
// 🔥 改动2:修改 _buildPlotListItem 方法,增加删除时调用Bloc的逻辑
Widget _buildPlotListItem(PlotData plot, Function(PlotData) onDelete) {
return GestureDetector(
// 核心:点击列表项触发选中逻辑
onTap: () async {
// 1. 清空之前的轨迹数据
setState(() {
gcjPathPoints = [];
gcjOuterPoints = [];
_currentTracePoints = null;
});
await context.read<DevicesCubit>().loadSelectedPath(plot.plotName);
final cubitState = context.read<DevicesCubit>().state;
final _loadedPlot = cubitState.pathData;
print('加载地块「${plot.plotName}」的路径数据: $_loadedPlot');
// 2. 立即解析并更新状态(在事件回调中,不在 build 中)
//if (_loadedPlot is List) {
// final List<dynamic> rawList = _loadedPlot as List<dynamic>;
// final pathRecords = rawList.where((item) => item is Map<String, dynamic>).cast<Map<String, dynamic>>().toList();
// // 打印:过滤后的 pathRecords 长度和第一条数据的顶层字段
// print('🔍 过滤后 pathRecords 长度: ${pathRecords.length}');
// if (pathRecords.isNotEmpty) {
// final Map<String, dynamic> firstRecord = pathRecords.first;
// print('📋 第一条记录的顶层字段:');
// print(' - id: ${firstRecord['id']}');
// print(' - workName: ${firstRecord['workName']}');
// print(' - userId: ${firstRecord['userId']}');
// print(' - jsonData (原始字符串长度): ${firstRecord['jsonData']?.toString().length ?? 0} 字符');
// print('------------------------------------');
// final selectedPlotPath = PlotDataPath(jsonData: firstRecord);
// try {
// final dynamic nestedJsonRaw = selectedPlotPath.jsonData['jsonData'];
// print('📦 嵌套 jsonData 原始类型: ${nestedJsonRaw.runtimeType}');
// print('📦 嵌套 jsonData 前200字符: ${nestedJsonRaw?.toString().substring(0, min(200, nestedJsonRaw?.toString().length ?? 0))}...');
// if (nestedJsonRaw != null && nestedJsonRaw is String) {
// final String nestedJsonStr = nestedJsonRaw;
// final dynamic parsedJson = jsonDecode(nestedJsonStr);
// print('✅ 嵌套 JSON 解析成功,类型: ${parsedJson.runtimeType}');
// // 格式化打印完整的嵌套 JSON(排除 img 字段,避免刷屏)
// if (parsedJson is Map<String, dynamic>) {
// final Map<String, dynamic> nestedJson = Map.from(parsedJson);
// // 截断 img 字段的 Base64 数据
// if (nestedJson.containsKey('img')) {
// final String imgBase64 = nestedJson['img'] as String;
// nestedJson['img'] = imgBase64.substring(0, min(50, imgBase64.length)) + '...(Base64 已截断)';
// }
// print('📝 格式化嵌套 JSON 数据:');
// print(const JsonEncoder.withIndent(' ').convert(nestedJson));
// print('------------------------------------');
// // 解析作业模式并打印
// final dynamic planModel = nestedJson['planModel'];
// workMode = planModel == WorkMode.bow.value ? "弓字模式" : "自定义模式";
// print('⚙️ 解析出作业模式: $workMode (原始 planModel 值: $planModel)');
// // 解析 Path 并打印明细
// final dynamic rawPath = nestedJson['path'];
// List<dynamic> pathList = rawPath is List ? rawPath : [];
// print('🗺️ 解析出 Path 数据:');
// print(' - Path 类型: ${rawPath.runtimeType}');
// print(' - Path 长度: ${pathList.length} 个点');
// // 逐点打印前 5 个 Path 坐标(避免点太多刷屏)
// for (int i = 0; i < min(5, pathList.length); i++) {
// final dynamic point = pathList[i];
// print(' - Path 点 $i: lat=${point['lat']}, lon=${point['lon']} (或 lng=${point['lng']})');
// }
// if (pathList.length > 5) {
// print(' - ... 还有 ${pathList.length - 5} 个点未显示');
// }
// // 解析 Outer 并打印明细
// final dynamic rawOuter = nestedJson['outer'];
// List<dynamic> outerList = rawOuter is List ? rawOuter : [];
// print('📏 解析出 Outer 外边界数据:');
// print(' - Outer 类型: ${rawOuter.runtimeType}');
// print(' - Outer 长度: ${outerList.length} 个点');
// // 逐点打印前 5 个 Outer 坐标
// for (int i = 0; i < min(5, outerList.length); i++) {
// final dynamic point = outerList[i];
// print(' - Outer 点 $i: lat=${point['lat']}, lon=${point['lon']} (或 lng=${point['lng']})');
// }
// if (outerList.length > 5) {
// print(' - ... 还有 ${outerList.length - 5} 个点未显示');
// }
// // 后续坐标转换逻辑(保持你的原有代码)
// // ...(这里放你原来的坐标转换、setState 逻辑)
// } else {
// print('❌ 解析失败:嵌套 JSON 不是 Map 类型,实际类型: ${parsedJson.runtimeType}');
// }
// } else {
// print('❌ 解析失败:nestedJsonRaw 为空或不是字符串,实际值: $nestedJsonRaw,类型: ${nestedJsonRaw?.runtimeType}');
// }
// } catch (e, stackTrace) {
// print('❌ 解析嵌套 JSON 时发生异常: $e');
// print('📜 异常堆栈: $stackTrace');
// }
// } else {
// print('❌ 过滤后 pathRecords 为空,无有效数据');
// }
//}
if (_loadedPlot is List) {
final List<dynamic> rawList = _loadedPlot as List<dynamic>;
final pathRecords = rawList.where((item) => item is Map<String, dynamic>).cast<Map<String, dynamic>>().toList();
if (pathRecords.isNotEmpty) {
final selectedPlotPath = PlotDataPath(jsonData: pathRecords.first);
try {
final dynamic nestedJsonRaw = selectedPlotPath.jsonData['jsonData'];
if (nestedJsonRaw != null && nestedJsonRaw is String) {
final String nestedJsonStr = nestedJsonRaw;
final dynamic parsedJson = jsonDecode(nestedJsonStr);
workMode = parsedJson['planModel'] == WorkMode.bow.value ? "弓字模式" : "自定义模式";
debugPrint('解析出的作业模式: ${parsedJson['planModel']},WorkMode.bow.value=${WorkMode.bow.value}, 解析结果: $workMode');
if (parsedJson is Map<String, dynamic>) {
final Map<String, dynamic> nestedJson = parsedJson;
// 替换你原来的 rawPath/rawOuter 解析部分
final dynamic rawPath = nestedJson['path'];
final dynamic rawOuter = nestedJson['outer'];
debugPrint('解析后的路径数据11: pathList=$rawPath,');
debugPrint('解析后的外边界数据22: outerList=$rawOuter,');
// ========== 修复核心:先解码字符串,再处理类型 ==========
List<dynamic> pathList = [];
if (rawPath is String) {
try {
pathList = jsonDecode(rawPath) as List<dynamic>;
} catch (e) {
debugPrint('path 字符串解码失败: $e, rawPath=$rawPath');
pathList = [];
}
} else if (rawPath is List) {
pathList = rawPath;
} else {
pathList = [];
}
List<dynamic> outerList = [];
if (rawOuter is String) {
try {
outerList = jsonDecode(rawOuter) as List<dynamic>;
} catch (e) {
debugPrint('outer 字符串解码失败: $e, rawOuter=$rawOuter');
outerList = [];
}
} else if (rawOuter is List) {
outerList = rawOuter;
} else {
outerList = [];
}
// ========== 修复结束 ==========
debugPrint('解析后的路径数据: pathList=$pathList, 长度=${pathList.length}');
debugPrint('解析后的外边界数据: outerList=$outerList, 长度=${outerList.length}');
List<LatLng> newPathPoints = [];
List<LatLng> newOuterPoints = [];
for (var item in pathList) {
double? lat = safeToDouble(item['lat']);
double? lon = safeToDouble(item['lon']);
if (lat != null && lon != null) {
newPathPoints.add(convertWGS84ToGCJ02(lat, lon));
}
}
for (var item in outerList) {
double? lng = safeToDouble(item['lng']);
double? lat = safeToDouble(item['lat']);
if (lng != null && lat != null) {
newOuterPoints.add(convertWGS84ToGCJ02(lat, lng));
}
}
// 在这里更新状态(安全,因为在事件回调中)
setState(() {
gcjPathPoints = newPathPoints;
gcjOuterPoints = newOuterPoints;
if (_currentWorkMode == WorkMode.custom) gcjPathPoints = newOuterPoints; // 自定义模式不显示路径
_selectedPlot = plot;
_isListBoxOpen = false;
_isWorkPanelOpen = true;
_isPanelOpen = false; // 关闭操作面板,专注作业界面
});
// 移动地图到中心
List<LatLng> allPoints = [];
allPoints.addAll(gcjPathPoints);
allPoints.addAll(gcjOuterPoints);
if (allPoints.isNotEmpty) {
moveMapToPointsCenter(allPoints);
}
}
}
} catch (e) {
print('解析/转换坐标失败:$e');
setState(() {
gcjPathPoints = [];
gcjOuterPoints = [];
_selectedPlot = plot;
_isListBoxOpen = false;
_isWorkPanelOpen = true;
_isPanelOpen = false; // 关闭操作面板,专注作业界面
});
}
} else {
setState(() {
gcjPathPoints = [];
gcjOuterPoints = [];
_selectedPlot = plot;
_isListBoxOpen = false;
_isWorkPanelOpen = true;
_isPanelOpen = false; // 关闭操作面板,专注作业界面
});
}
} else {
setState(() {
gcjPathPoints = [];
gcjOuterPoints = [];
_selectedPlot = plot;
_isListBoxOpen = false;
_isWorkPanelOpen = true;
});
}
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _selectedPlot?.id == plot.id ? Colors.blue : Colors.grey[100]!,
width: _selectedPlot?.id == plot.id ? 2 : 1, // 选中项高亮边框
),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
plot.imageUrl,
width: 30,
height: 30,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 30,
height: 30,
color: Colors.grey[200],
child: const Icon(Icons.image_outlined, color: Colors.grey),
);
},
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
plot.plotName,
style: const TextStyle(fontSize: 16, color: Colors.black87),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
onPressed: () => _showDeleteConfirmDialog(plot, onDelete),
icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
);
}
Widget _buildMap(double lat, double lng, bool obfFlag, int headingStatus, String controlMode) {
if (lat != 0 && lat != 0 && (lat != _currentLatLng?.latitude || lng != _currentLatLng?.longitude)) {
_onDeviceData(lng, lat, obfFlag, headingStatus, controlMode);
}
final gcjPoint = wgs84ToGcj02(lat, lng);
// 关键:判断Widget是否还挂载,避免空指针
_currentLatLng = gcjPoint; // ✅ 状态变量在setState内更新
return FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: _currentLatLng ?? const LatLng(39.9042, 116.4074),
initialZoom: 20,
maxZoom: 22,
// 禁止地图点击事件(避免和中心标冲突)
onTap: (_, __) {}, // 空实现,禁用地图点击响应
),
children: [
/// 高德瓦片(GCJ-02)
TileLayer(
urlTemplate:
'https://webrd02.is.autonavi.com/appmaptile'
'?style=8&x={x}&y={y}&z={z}&lang=zh_cn&size=1&scale=1'
'&key=bbb1f0f20eed6bf679eddf2625630aba',
),
// 绘制path折线(Line模式)
if (gcjPathPoints.isNotEmpty)
PolylineLayer(
polylines: [
Polyline(
points: gcjPathPoints, // 转换后的GCJ02坐标
color: const Color.fromARGB(255, 223, 228, 116), // 折线颜色(可自定义)
strokeWidth: 1.0, // 折线宽度
isDotted: false, // 非虚线(Line模式)
borderColor: Colors.white, // 可选:添加白色描边,提升辨识度
borderStrokeWidth: 0.5,
),
],
),
// 绘制outer边框(Polygon模式)
if (gcjOuterPoints.isNotEmpty && _currentWorkMode == WorkMode.bow)
PolygonLayer(
polygons: [
Polygon(
points: gcjOuterPoints, // 转换后的GCJ02坐标
color: Colors.green.withOpacity(0.1), // 内部填充色(透明)
borderColor: Colors.green, // 边框颜色
borderStrokeWidth: 1.0, // 边框宽度
isFilled: true, // 开启填充(即使透明,也需要开启才能显示边框)
),
],
),
if (gcjPathPoints.isNotEmpty && _currentWorkMode == WorkMode.custom)
PolylineLayer(
polylines: [
Polyline(
points: gcjPathPoints, // 转换后的GCJ02坐标
color: Colors.green, // 内部填充色(透明)
strokeWidth: 3.0, // 折线宽度
isDotted: false, // 非虚线(Line模式)
borderColor: Colors.white, // 可选:添加白色描边,提升辨识度
borderStrokeWidth: 0.5,
),
],
),
/// 中心标与历史打点的虚线连线
if (!_isWorkAreaCompleted)
PolylineLayer(
polylines: [
for (int i = 0; i < _markedPoints.length - 1; i++)
Polyline(points: [_markedPoints[i], _markedPoints[i + 1]], color: Colors.orange.withOpacity(0.5), strokeWidth: 1.5),
],
),
if (_markedPoints.isNotEmpty && !_isWorkAreaCompleted)
PolylineLayer(
polylines: [
Polyline(points: [_mapCenter, _markedPoints.last], color: Colors.blue.withOpacity(0.5), strokeWidth: 1.5),
],
),
/// 当前定位 Marker
if (_currentLatLng != null)
MarkerLayer(
markers: [
Marker(
point: _currentLatLng!,
width: 40,
height: 40,
child: CustomPaint(
size: const Size(40, 40),
painter: HeadingMarkerPainter(headingAngle: _headingAngle),
),
),
],
),
if (_currentWorkMode == WorkMode.bow && _markedPoints.isNotEmpty && !_isWorkAreaCompleted)
PolygonLayer(
polygons: [
Polygon(
points: _getPolygonPoints(),
color: Colors.green.withOpacity(0.2),
borderColor: Colors.green.withOpacity(0.5),
borderStrokeWidth: 1,
isFilled: true,
),
],
),
if (_obstacleHoles.isNotEmpty && _isObstacleEditing)
PolygonLayer(
polygons: _obstacleHoles.map((holePoints) {
return Polygon(
points: holePoints,
color: Colors.red.withOpacity(0.2), // 红色半透明填充
borderColor: Colors.red, // 红色边框
borderStrokeWidth: 1.5,
isFilled: true,
);
}).toList(),
),
if (gctracePoint!.isNotEmpty)
PolylineLayer(
polylines: [
// 已绘制的障碍物点连线
for (int i = 0; i < gctracePoint!.length - 1; i++)
Polyline(
points: [gctracePoint![i], gctracePoint![i + 1]],
color: Colors.white,
strokeWidth: 1.5,
isDotted: true, // 虚线区分作业区域
),
],
),
// 2. 正在绘制的障碍物打点连线(红色虚线)
if (_isObstacleEditing && _currentObstaclePoints.isNotEmpty)
PolylineLayer(
polylines: [
// 已绘制的障碍物点连线
for (int i = 0; i < _currentObstaclePoints.length - 1; i++)
Polyline(
points: [_currentObstaclePoints[i], _currentObstaclePoints[i + 1]],
color: Colors.red.withOpacity(0.8),
strokeWidth: 1.5,
isDotted: true, // 虚线区分作业区域
),
// 最后一个点到地图中心的连线
Polyline(points: [_mapCenter, _currentObstaclePoints.last], color: Colors.red.withOpacity(0.5), strokeWidth: 1.5, isDotted: true),
],
),
// 3. 障碍物打点标记(红色)
if (_currentAreaMode == AreaMode.obstacle && _isObstacleEditing)
MarkerLayer(
markers: [
// 已完成的障碍物打点
for (var holeIndex = 0; holeIndex < _obstacleHoles.length; holeIndex++)
for (var pointIndex = 0; pointIndex < _obstacleHoles[holeIndex].length; pointIndex++)
Marker(
point: _obstacleHoles[holeIndex][pointIndex],
width: 80,
height: 40,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 16,
height: 16,
decoration: const BoxDecoration(
color: Colors.red, // 红色标记区分作业区域
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 2)],
),
child: Center(
child: Text(
'${holeIndex + 1}-${pointIndex + 1}', // 格式:组号-点号
style: const TextStyle(color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold),
),
),
),
],
),
),
// 正在绘制的障碍物打点
for (var pointIndex = 0; pointIndex < _currentObstaclePoints.length; pointIndex++)
Marker(
point: _currentObstaclePoints[pointIndex],
width: 80,
height: 40,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 16,
height: 16,
decoration: const BoxDecoration(
color: Colors.orange, // 橙色标记区分正在绘制
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 2)],
),
child: Center(
child: Text(
'${pointIndex + 1}',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
),
),
],
),
),
],
),
if (!_isWorkAreaCompleted)
/// 历史打点的绿色标记
MarkerLayer(
markers: _markedPoints.asMap().entries.map((entry) {
int index = entry.key + 1; // 打点序号(从1开始)
LatLng point = entry.value;
return Marker(
point: point,
width: 80,
height: 40,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8), // 向下偏移8px(抵消默认的顶部对齐)
// 绿色打点标记
Container(
width: 16,
height: 16,
decoration: const BoxDecoration(
color: Color(0xFF00C853), // 绿色主题色
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 2)],
),
child: Center(
child: Text(
'$index',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
),
),
],
),
);
}).toList(),
),
],
);
}
/// 开始作业
void _startWork() async {
if (gcjPathPoints.isEmpty) {
ToastUtils.showInfo(context, '作业列表为空,请重新选择路径');
return;
}
isStartWork = true;
_traceManager.setMode(TPMode.NAVIGATION);
setState(() {
_workStatus = WorkStatus.working;
});
// 步骤1:将 List<LatLng> 转换为 List<DeviceAddPathPointModel>
final List<work_area_model.DeviceAddPathPointModel> pathModels = gcjPathPoints.map((latLng) {
return work_area_model.DeviceAddPathPointModel(
latitude: latLng.latitude, // 纬度
longitude: latLng.longitude, // 经度
);
}).toList();
// 步骤2:将 List 转换为 Queue(队列)
final Queue<work_area_model.DeviceAddPathPointModel> pathQueue = Queue.from(pathModels);
// 步骤3:调用 Cubit 方法(类型匹配)
await context.read<DevicesCubit>().startRoutePlanning(pathQueue);
debugPrint('开始作业:${_selectedPlot!.plotName},路径数据:$gcjPathPoints');
// 可选:显示作业提示
ToastUtils.showSuccess(context, '作业已开始');
//ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('作业已开始'), backgroundColor: Colors.green));
}
/// 暂停作业
void _pauseWork() async {
setState(() {
_workStatus = WorkStatus.paused;
});
await context.read<DevicesCubit>().pauseRoutePlanning();
debugPrint('暂停作业:${_selectedPlot!.plotName}');
}
void _stopWork() async {
setState(() {
_workStatus = WorkStatus.idle;
});
await context.read<DevicesCubit>().stopRoutePlanning();
debugPrint('停止作业:${_selectedPlot!.plotName}');
ToastUtils.showError(context, '作业已停止');
}
/// 继续作业
void _resumeWork() async {
setState(() {
_workStatus = WorkStatus.working;
});
await context.read<DevicesCubit>().resumeRoutePlanning();
}
void _showDeleteConfirmDialog(PlotData plot, Function(PlotData) onDelete) {
ConfirmDialog.show(
context: context,
title: '确认删除',
content: '是否删除地块「${plot.plotName}」?删除后不可恢复。',
confirmText: '删除',
confirmTextColor: Colors.red,
onConfirm: () {
onDelete(plot);
},
);
}
Widget _buildWorkPanel() {
return BlocBuilder<DevicesCubit, DevicesState>(
builder: (context, state) {
// 无选中地块或无路径数据时返回空
if (_selectedPlot == null || state.pathData == null) {
return const SizedBox.shrink();
}
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, -2))],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 1. 地块基础信息
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
_selectedPlot!.imageUrl,
width: 80,
height: 80,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 80,
height: 80,
color: Colors.grey[200],
child: const Icon(Icons.image_outlined, color: Colors.grey, size: 32),
);
},
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_selectedPlot!.plotName,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text('作业模式:$workMode', style: const TextStyle(fontSize: 14, color: Colors.grey)),
],
),
),
IconButton(
onPressed: () {
setState(() {
_isWorkPanelOpen = false;
_selectedPlot = null;
gcjPathPoints = [];
gcjOuterPoints = [];
});
},
icon: const Icon(Icons.close, color: Colors.grey, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
),
],
),
const SizedBox(height: 16),
// 2. 错误提示(如果加载失败)
if (state.errorMessage != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.red[50],
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.red[200]!),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.redAccent, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
state.errorMessage!,
style: const TextStyle(fontSize: 14, color: Colors.redAccent),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// 3. 开始作业按钮(加载中禁用)
// 放在 Column/Row 的 children: [ ... ] 内部
_workStatus == WorkStatus.idle
? SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: state.isLoading || gcjPathPoints.isEmpty
? null
: () {
// 开始作业逻辑
_startWork();
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF00C853),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 2,
disabledBackgroundColor: Colors.grey[300],
disabledForegroundColor: Colors.grey[600],
),
child: state.isLoading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('开始作业', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
),
)
: Row(
children: [
// 暂停/继续图标按钮
Expanded(
child: SizedBox(
width: 50,
height: 50,
child: ElevatedButton(
onPressed: () => _workStatus == WorkStatus.working ? _pauseWork() : _resumeWork(),
style: ElevatedButton.styleFrom(
backgroundColor: _workStatus == WorkStatus.working ? Colors.amber : const Color(0xFF00C853),
foregroundColor: Colors.white,
shape: const CircleBorder(),
elevation: 2,
// 调整内边距,让图标居中更协调
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: Icon(
// 作业中显示暂停图标,暂停中显示播放(继续)图标
_workStatus == WorkStatus.working ? Icons.pause : Icons.play_arrow,
size: 24, // 图标尺寸
),
),
),
),
const SizedBox(width: 12),
// 停止图标按钮
Expanded(
child: SizedBox(
width: 50,
height: 50,
child: ElevatedButton(
onPressed: () => _stopWork(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
foregroundColor: Colors.white,
shape: const CircleBorder(),
elevation: 2,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: const Icon(
Icons.stop, // 停止图标(也可以用 Icons.close)
size: 24,
),
),
),
),
],
),
//SizedBox(
// width: double.infinity,
// height: 50,
// child: ElevatedButton(
// onPressed: state.isLoading || gcjPathPoints.isEmpty
// ? null
// : () {
// // 作业逻辑:使用加载的pathData
// debugPrint('开始作业:${_selectedPlot!.plotName},路径数据:$gcjPathPoints');
// },
// style: ElevatedButton.styleFrom(
// backgroundColor: const Color(0xFF00C853),
// foregroundColor: Colors.white,
// shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
// elevation: 2,
// disabledBackgroundColor: Colors.grey[300],
// disabledForegroundColor: Colors.grey[600],
// ),
// child: state.isLoading
// ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
// : const Text('开始作业', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
// ),
//),
],
),
),
);
},
);
}
Future<String?> _captureMapToJpgBase64({int quality = 80}) async {
try {
// 1. 第一步:截取 PNG (Flutter 唯一支持的格式)
RenderRepaintBoundary? boundary = _mapRepaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) {
debugPrint('截图失败:渲染对象未找到');
return null;
}
// 🔥 注意:这里是 dart:ui 的 Image,需显式指定 ui.Image
ui.Image image = await boundary.toImage(pixelRatio: 2.0);
ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) {
debugPrint('截图失败:字节数据为空');
return null;
}
Uint8List pngBytes = byteData.buffer.asUint8List();
// 2. 第二步:使用 image 库解码
// 🚨 核心修复:变量类型必须显式写为 _image.Image?
_image.Image? decodedImage = _image.decodeImage(pngBytes);
if (decodedImage == null) {
debugPrint('转换失败:无法解码 PNG');
return null;
}
// 3. 第三步:编码为 JPG
List<int> jpgBytes = _image.encodeJpg(decodedImage, quality: quality);
Uint8List finalBytes = Uint8List.fromList(jpgBytes);
// 4. 第四步:转换为 Base64
String base64Str = base64Encode(finalBytes);
debugPrint('JPG Base64 长度:${base64Str.length} (体积更小)');
return base64Str;
} catch (e) {
debugPrint('生成 JPG Base64 异常:$e');
return null;
}
}
// 🔥 改动4:新增方法 - 将Bloc的workRecords转换为PlotData列表
List<PlotData> _convertWorkRecordsToPlotData(List<Map<String, dynamic>> records) {
return records.map((record) {
return PlotData(
id: record['id']?.toString() ?? DateTime.now().microsecondsSinceEpoch.toString(), // 唯一ID
plotName: record['workName'] ?? '未命名地块', // 地块名称(从接口字段取)
imageUrl: record['imgUrl'] ?? '', // 图片URL(从接口字段取,无则为空)
jsonData: record['jsonData'], // 原始数据的JSON字符串(可选,便于调试或后续使用)
);
}).toList();
}
// ========== 抽象:生成路径的核心函数 ==========
Future<void> _generatePath({bool showTips = true}) async {
if (_currentWorkMode == WorkMode.custom) {
setState(() {
_saveBoxOpen = true; // 显示保存按钮
_isWorkAreaCompleted = true; // 标记为完成,允许保存
gcjPathPoints = List.from(_markedPoints); // outer直接使用打点坐标
gcjOuterPoints = List.from(_markedPoints);
typedPathList = []; // 路径列表置空
});
return;
}
// 1. 校验必要条件
if (_currentAreaMode == AreaMode.work && _markedPoints.isEmpty) {
if (showTips) {
ToastUtils.showError(context, '请先添加作业区域打点!');
//ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请先添加作业区域打点!'), backgroundColor: Colors.red));
}
return;
}
if (_currentAreaMode == AreaMode.work && _markedPoints.length < 3) {
if (showTips) {
ToastUtils.showError(context, '作业区域至少需要3个打点才能生成路径!');
}
return;
}
if (_currentWorkMode == null) {
if (showTips) {
ToastUtils.showError(context, '请先选择作业模式!');
//ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请先选择作业模式!'), backgroundColor: Colors.red));
}
return;
}
try {
// 2. 转换页面数据为 generatePath 所需的参数格式
// 2.1 参考点(取作业区域第一个打点)
final firstPoint = _markedPoints.first;
final wgs84FirstPoint = gcj02ToWgs84(firstPoint.latitude, firstPoint.longitude);
final referencePoint = work_area_model.ReferencePoint(lat: wgs84FirstPoint.latitude, lon: wgs84FirstPoint.longitude);
// 2.2 航向角
final heading = _angle == -1 ? -1 : _angle.toInt();
// 2.3 外边界(作业区域)
final outerBoundary = work_area_model.OuterBoundary(
position: _markedPoints.map((latLng) {
final wgs84Point = gcj02ToWgs84(latLng.latitude, latLng.longitude);
return work_area_model.Position(lat: wgs84Point.latitude, lon: wgs84Point.longitude);
}).toList(),
sideWidth: _workDistance,
);
work_area_model.HoleBoundary? holeBoundary;
if (_obstacleHoles.isNotEmpty) {
// 1. 构造二维 Position 数组
List<List<work_area_model.Position>> positionList = [];
for (var holePoints in _obstacleHoles) {
List<work_area_model.Position> innerPositionList = holePoints.map((latLng) {
final wgs84Point = gcj02ToWgs84(latLng.latitude, latLng.longitude);
return work_area_model.Position(lat: wgs84Point.latitude, lon: wgs84Point.longitude);
}).toList();
positionList.add(innerPositionList);
}
// 2. 创建 HoleBoundary 对象(无外层 Map)
holeBoundary = work_area_model.HoleBoundary(position: positionList, sideWidth: 0.0);
}
// 🔥 第二步:构造符合方法参数要求的 Map<String, HoleBoundary>
// 用一个临时 Map 来满足类型要求,后续在 Cubit 中取值即可
Map<String, work_area_model.HoleBoundary> holes = {};
if (holeBoundary != null) {
holes = {'tempKey': holeBoundary}; // 键名任意,仅为满足类型要求
}
final workType = _currentWorkMode == WorkMode.bow ? 0 : 2;
// 3. 调用 Cubit 的 generatePath 方法(传入holes参数)
await context.read<DevicesCubit>().generatePath(
reference: referencePoint,
heading: heading,
outer: outerBoundary,
holes: holes.isNotEmpty ? holes : {}, // 无障碍物时传空对象
workType: workType,
);
// 4. 处理生成结果(原有逻辑不变)
final cubitState = context.read<DevicesCubit>().state;
print('${cubitState.generatedPath},路径生成结果');
if (cubitState.errorMessage != null) {
if (showTips) {
ToastUtils.showError(context, '路径生成失败:${cubitState.errorMessage}');
//ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('路径生成失败:${cubitState.errorMessage}'), backgroundColor: Colors.red));
}
} else if (cubitState.generatedPath != null) {
if (showTips) {
ToastUtils.showSuccess(context, '路径生成成功!');
}
debugPrint('生成的路径数据:${cubitState.generatedPath}');
if (cubitState.generatedPath is List) {
setState(() {
_isWorkAreaCompleted = true;
_saveBoxOpen = true;
final pathList = cubitState.generatedPath as List;
typedPathList = pathList.whereType<work_area_model.DeviceAddPathPointModel>().toList();
debugPrint('✅ 过滤并转换后的路径点列表:typedPathList 长度=${typedPathList.length}');
//打印
gcjPathPoints = typedPathList.map((point) {
double wgs84Lat = point.latitude;
double wgs84Lon = point.longitude;
final gcjPoint = wgs84ToGcj02(wgs84Lat, wgs84Lon);
return LatLng(gcjPoint.latitude, gcjPoint.longitude);
}).toList();
gcjOuterPoints = List.from(_markedPoints);
//_markedPoints.clear();
});
moveMapToPointsCenter(gcjPathPoints);
}
}
} catch (e) {
debugPrint('路径生成异常:$e');
if (showTips) {
ToastUtils.showError(context, '路径生成异常:${e.toString().substring(0, 50)}');
//ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('路径生成异常:${e.toString().substring(0, 50)}'), backgroundColor: Colors.red));
}
}
}
@override
Widget build(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
final menuHeight = 16 * 6; // 假设 VerticalFloatMenu 有 6 个选项,每个高度为 56
final maxTop = screenHeight - menuHeight;
final userState = context.watch<AppUserCubit>().state;
final deviceId = context.watch<DevicesCubit>().state.selectedDevice?.deviceName;
if (deviceId != null && userState.user?.token != null) {
_videoStreamUrl = "webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=${userState.user!.token}";
} else {
_videoStreamUrl = ''; // 无设备/用户信息时置空
print("设备未选中或用户未登录,无法生成视频流地址");
}
return BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
builder: (context, state) {
// 初始化默认值
String yaw = '--';
String satelliteCnt = '--';
double currentLat = 0.0;
double currentLng = 0.0;
bool obfFlag = false; //障碍物标志位
int headingStatus = 0;
String controlMode = "0";
DeviceStatusUpdated? updatedState;
if (state is DeviceStatusUpdated) {
// 强转获取具体的状态数据
updatedState = state as DeviceStatusUpdated;
obfFlag = updatedState.status.obstacleFlag.toString() == 1;
headingStatus = updatedState.status.headingStatus;
controlMode = updatedState.status.controlMode;
_headingAngle = updatedState.status.yaw;
debugPrint("${updatedState.status} 路径规划四十数据");
debugPrint("路径规划四十数据 - 经纬度:");
debugPrint(" RunningStatus纬度:${updatedState.status.latitude}");
debugPrint(" RunningStatus经度:${updatedState.status.longitude}");
debugPrint("${updatedState.status.obstacleFlag},${updatedState.status.qual},${updatedState.status.controlMode}");
debugPrint("${updatedState.status.headingStatus},${updatedState.status.yaw}");
// 提取经纬度并更新本地变量
currentLat = updatedState.status.latitude;
currentLng = updatedState.status.longitude;
} else {}
return Scaffold(
body: SafeArea(
child: Stack(
children: [
RepaintBoundary(key: _mapRepaintKey, child: _buildMap(currentLat, currentLng, obfFlag, headingStatus, controlMode)),
// 地图核心组件
if (_isRefreshing)
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.3),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [CircularProgressIndicator(color: Colors.white, strokeWidth: 3)],
),
),
),
),
// 悬浮返回按钮(核心修改)
Positioned(
top: 10,
left: 10,
child: GestureDetector(
// 自定义点击事件
onTap: _navigateBack,
// 自定义点击反馈(替代 IconButton 的高亮/水波纹)
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 3, offset: const Offset(0, 2))],
),
// 核心:用 Stack 实现点击高亮层 + 图标层
child: Stack(
alignment: Alignment.center, // 所有子组件居中
children: [
// 1. 点击高亮层(默认隐藏,点击时显示)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Colors.transparent, // 默认透明
borderRadius: BorderRadius.circular(20),
),
),
),
// 2. 图标层(绝对居中)
const Icon(Icons.arrow_back_ios, color: Colors.black87, size: 20),
],
),
),
),
),
/// 核心:地图正中心固定定位标
Positioned(
left: 0,
right: 0,
top: 0,
bottom: 0,
child: Center(
// 自定义十字标(中心精准对齐地图中心)
child: SizedBox(
width: 16, // 十字整体宽度
height: 16, // 十字整体高度
child: CustomPaint(
painter: CrosshairPainter(), // 自定义十字画笔
),
),
),
),
//保存按钮
if (_saveBoxOpen)
Positioned(
right: 80,
top: maxTop > 10 ? 11 : maxTop,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFF00C853),
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4, offset: const Offset(0, 2))],
),
child: IconButton(
onPressed: () {
_showSavePlotDialog();
},
icon: const Icon(Icons.save, color: Colors.white, size: 18),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
),
// 右侧悬浮菜单
Positioned(
height: 336,
right: 16,
top: maxTop > 10 ? 11 : maxTop,
child: FloatingActionButton(
onPressed: _moveToCurrentLocation,
child: VerticalFloatMenu(
onEditTap: (bool isOpen) {
setState(() {
_isPanelOpen = isOpen;
});
},
onListBox: (bool isOpen) {
// 🔥 改动5:加载作业记录(原有逻辑保留)
final userId = context.read<AppUserCubit>().state.user?.userId ?? "";
print('加载作业记录,当前用户ID:$userId');
context.read<DevicesCubit>().loadWorkRecords(userId);
setState(() {
_isListBoxOpen = isOpen;
_isWorkPanelOpen = false;
});
},
onWorkModeSelected: (mode) {
_currentWorkMode = mode;
debugPrint('外部收到作业模式:$mode');
},
onRefreshTap: _handleRefresh, // 推荐用局部刷新
onVideoTap: _handleVideo,
),
),
),
// 底部操作面板
if (_isPanelOpen)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: BottomOperationPanel(
initialRobotMode: RobotMode.point,
initialAreaMode: AreaMode.work,
initialWorkMode: _currentWorkMode!,
canUndo: _isWorkAreaCompleted && _currentAreaMode == AreaMode.work ? false : true,
onComplete: () async {
debugPrint(
'操作完成回调:当前机器人模式=$_currentRobotMode,当前区域模式=$_currentAreaMode,当前作业模式:$_currentWorkMode,作业区域点:$_markedPoints,作业行距=$_workDistance,航线方向角=$_angle',
);
if (_currentAreaMode == AreaMode.obstacle) {
// 障碍物模式:完成当前障碍物绘制
_completeObstacle();
await _generatePath(showTips: true);
} else {
// 作业区域模式:生成路径
await _generatePath(showTips: true);
}
// 5. 关闭操作面板
setState(() {
//_isPanelOpen = false; // 关闭操作面板
});
},
onUndoTap: () {
// 区分模式:作业区域撤回/障碍物撤回
//if (_currentAreaMode == AreaMode.work) {
// _undoLastPoint();
//} else {
// _undoObstaclePoint();
//}
_undoAction();
},
onDeleteTap: () {
_deleteAllAction();
// 区分模式:作业区域清空/障碍物清空
//if (_currentAreaMode == AreaMode.work) {
// _deleteAllPoints();
//} else {
// _clearAllObstacles();
//}
},
onRobotModeChanged: (mode) {
setState(() => _currentRobotMode = mode);
debugPrint('外部收到机器人模式:$mode');
},
onAreaModeChanged: (mode) {
setState(() => _currentAreaMode = mode);
debugPrint('外部收到区域模式:$mode');
},
onAddTap: () {
_addMarkedPoint();
debugPrint('外部处理加号按钮点击,已添加打点');
},
onDistanceTap: (distance) {
_workDistance = distance;
debugPrint('外部处理作业行距距离设置,当前距离:${distance.toStringAsFixed(1)}米');
if (_markedPoints.length >= 3 && _currentWorkMode != null) {
_generatePath(showTips: false);
}
},
onSettingTap: () {
setState(() {
_directionBoxOpen = true;
});
debugPrint('外部处理设置按钮点击');
},
onLandTap: () {
debugPrint('外部处理地块标签点击');
},
onRouteTap: () {
debugPrint('外部处理航线标签点击');
},
),
),
// 航线方向面板
if (_directionBoxOpen)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: RouteDirectionPanel(
initialOptimalHeading: true,
initialDirection: 0.0,
onValueChanged: (result) {
debugPrint('最优航向:${result['optimalHeading']},角度:${result['direction']}');
_angle = result['optimalHeading'] == true ? -1 : result['direction'];
debugPrint('外部处理航线方向设置,当前角度:${_angle}');
if (_markedPoints.length >= 3 && _currentWorkMode != null) {
debugPrint('外部处理航线方向设置11222:${_angle}');
_generatePath(showTips: false);
}
},
onCancel: () {
setState(() => _directionBoxOpen = false);
},
),
),
// 列表面板 - 使用BlocBuilder监听DevicesCubit状态
if (_isListBoxOpen)
BlocBuilder<DevicesCubit, DevicesState>(
builder: (context, state) {
final plotList = _convertWorkRecordsToPlotData(state.workRecords ?? []);
return Positioned.fill(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Expanded(
child: GestureDetector(
onTap: () {
setState(() {
_isListBoxOpen = false;
});
},
child: Container(color: Colors.black.withOpacity(0.3)),
),
),
Container(
width: double.infinity,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, -2))],
),
constraints: const BoxConstraints(maxHeight: 600, minHeight: 200),
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'地块列表(共${plotList.length}条)',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87),
),
IconButton(
onPressed: () {
setState(() {
_isListBoxOpen = false;
});
},
icon: const Icon(Icons.close, color: Colors.grey, size: 20),
),
],
),
),
const Divider(height: 1, color: Colors.grey),
Expanded(
child: plotList.isEmpty
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, color: Colors.grey, size: 48),
SizedBox(height: 16),
Text('暂无地块数据', style: TextStyle(color: Colors.grey, fontSize: 16)),
],
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: plotList.length,
itemBuilder: (context, index) {
final plot = plotList[index];
// 传入删除回调(调用Bloc的删除方法)
return _buildPlotListItem(plot, (deletedPlot) async {
await context.read<DevicesCubit>().deleteWorkRecord(deletedPlot.plotName);
final userId = context.read<AppUserCubit>().state.user?.userId ?? "";
await context.read<DevicesCubit>().loadWorkRecords(userId);
setState(() {});
});
},
),
),
],
),
),
],
),
);
},
),
if (_isWorkPanelOpen) _buildWorkPanel(),
if (_isVideoDialogOpen) _buildVideoPopup(),
],
),
),
);
},
);
}
}
// 转换单个WGS84坐标到GCJ02
LatLng convertWGS84ToGCJ02(double lat, double lon) {
return wgs84ToGcj02(lat, lon);
}
/// =======================================================
/// 坐标转换:WGS84 -> GCJ02(国内高德/腾讯通用)
/// =======================================================
const double _pi = 3.14159265358979324;
const double _a = 6378245.0;
const double _ee = 0.00669342162296594323;
LatLng wgs84ToGcj02(double lat, double lon) {
if (_outOfChina(lat, lon)) {
return LatLng(lat, lon);
}
double dLat = _transformLat(lon - 105.0, lat - 35.0);
double dLon = _transformLon(lon - 105.0, lat - 35.0);
double radLat = lat / 180.0 * _pi;
double magic = sin(radLat);
magic = 1 - _ee * magic * magic;
double sqrtMagic = sqrt(magic);
dLat = (dLat * 180.0) / ((_a * (1 - _ee)) / (magic * sqrtMagic) * _pi);
dLon = (dLon * 180.0) / (_a / sqrtMagic * cos(radLat) * _pi);
double mgLat = lat + dLat;
double mgLon = lon + dLon;
return LatLng(mgLat, mgLon);
}
List<LatLng> batchWgs84ToGcj02(List<LatLng> wgs84Points) {
// 遍历每个点,调用你已有的单个转换方法
return wgs84Points.map((point) {
return wgs84ToGcj02(point.latitude, point.longitude);
}).toList();
}
LatLng gcj02ToWgs84(double lat, double lon) {
if (_outOfChina(lat, lon)) {
return LatLng(lat, lon);
}
double dLat = _transformLat(lon - 105.0, lat - 35.0);
double dLon = _transformLon(lon - 105.0, lat - 35.0);
double radLat = lat / 180.0 * _pi;
double magic = sin(radLat);
magic = 1 - _ee * magic * magic;
double sqrtMagic = sqrt(magic);
dLat = (dLat * 180.0) / ((_a * (1 - _ee)) / (magic * sqrtMagic) * _pi);
dLon = (dLon * 180.0) / (_a / sqrtMagic * cos(radLat) * _pi);
double mgLat = lat - dLat;
double mgLon = lon - dLon;
return LatLng(mgLat, mgLon);
}
bool _outOfChina(double lat, double lon) {
return lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271;
}
double _transformLat(double x, double y) {
double ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * sqrt(x.abs());
ret += (20.0 * sin(6.0 * x * _pi) + 20.0 * sin(2.0 * x * _pi)) * 2.0 / 3.0;
ret += (20.0 * sin(y * _pi) + 40.0 * sin(y / 3.0 * _pi)) * 2.0 / 3.0;
ret += (160.0 * sin(y / 12.0 * _pi) + 320 * sin(y * _pi / 30.0)) * 2.0 / 3.0;
return ret;
}
double _transformLon(double x, double y) {
double ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * sqrt(x.abs());
ret += (20.0 * sin(6.0 * x * _pi) + 20.0 * sin(2.0 * x * _pi)) * 2.0 / 3.0;
ret += (20.0 * sin(x * _pi) + 40.0 * sin(x / 3.0 * _pi)) * 2.0 / 3.0;
ret += (150.0 * sin(x / 12.0 * _pi) + 300.0 * sin(x / 30.0 * _pi)) * 2.0 / 3.0;
return ret;
}