5308 lines
210 KiB
Dart
5308 lines
210 KiB
Dart
import 'dart:async';
|
||
import 'dart:collection';
|
||
import 'dart:convert';
|
||
import 'dart:math';
|
||
import 'dart:math' as math;
|
||
import 'dart:typed_data';
|
||
import 'package:dio/dio.dart';
|
||
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:get_it/get_it.dart';
|
||
import 'package:go_router/go_router.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/features/remote_control/presentation/bloc/remote_control_cubit.dart';
|
||
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
|
||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||
import 'package:maibu_satabot_v2/core/di/injection.dart';
|
||
import 'package:maibu_satabot_v2/core/localization/app_localizations.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/core/network/mqtt/domain/repositories/mower_realtime_repository.dart';
|
||
import 'package:maibu_satabot_v2/core/router/route_paths.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/create_device_task_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/devices/domain/entities/device_task_entity.dart'; // 🔥 新增
|
||
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_task_cubit.dart'; // 🔥 新增
|
||
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_task_state.dart'; // 🔥 新增
|
||
import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart';
|
||
import 'package:maibu_satabot_v2/features/home/presentation/widgets/BottomDirectionLine.dart';
|
||
import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/commonFn.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/map/dji_tile_layer.dart';
|
||
import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/geoserver_suzhou_layer.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 'package:shared_preferences/shared_preferences.dart';
|
||
import 'dart:ui' as ui;
|
||
|
||
import '../path_list_pages.dart';
|
||
import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart';
|
||
|
||
// 定义轨迹点类型(经纬度)
|
||
typedef PlotPoint = LatLng;
|
||
// 本地存储Key常量
|
||
const String kSavedPlotData = 'saved_plot_data';
|
||
const String kSavedGcjPathPoints = 'saved_gcj_path_points';
|
||
const String kSavedGcjOuterPoints = 'saved_gcj_outer_points';
|
||
const String kSavedMarkedPoints = 'saved_marked_points';
|
||
const String kSavedObstacleHoles = 'saved_obstacle_holes';
|
||
const String kSavedIsWorkAreaCompleted = 'saved_is_work_area_completed';
|
||
const String kSavedRobotModeWgsPoints = "saved_robot_mode_wgs_points";
|
||
const String kSavedIsWorkPanelOpen = "saved_is_work_panel_open";
|
||
const String kSavedSelectedPlot = 'saved_selected_plot';
|
||
const String kSavedStartWorkList = 'saved_start_work_list';
|
||
const String kSavedWorkMode = 'saved_work_mode';
|
||
const String kSavedWorkStatus =
|
||
'saved_work_status'; // 作业状态(idle/working/paused)
|
||
const String kSavedIsStartWork = 'saved_is_start_work'; // 是否开始作业
|
||
const String kSavedIsStopWork = 'saved_is_stop_work'; // 是否点击过停止
|
||
const String kSavedTPMode = "saved_tp_mode";
|
||
const String kSavedCurrentRobotMode = "kSavedCurrentRobotMode";
|
||
const String kSavedIsPanelOpen = "kSavedIsPanelOpen";
|
||
const String kSavedCurrentWorkMode = "kSavedCurrentWorkMode";
|
||
const String kSavedDeviceTaskIds =
|
||
"saved_device_task_ids"; // {deviceId: taskId} Map
|
||
|
||
// 保持 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 _ToastItem {
|
||
final String message;
|
||
final ToastType type;
|
||
|
||
_ToastItem({required this.message, required this.type});
|
||
}
|
||
|
||
class MapPageEnterprise extends StatefulWidget {
|
||
const MapPageEnterprise({Key? key}) : super(key: key);
|
||
|
||
@override
|
||
State<MapPageEnterprise> createState() => _MapPageEnterpriseState();
|
||
}
|
||
|
||
class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||
final ILoggerService _logger = GetIt.I<ILoggerService>();
|
||
|
||
final _dispatcher = sl<NetMessageDispatcher>();
|
||
bool _isRefreshing = false; // 新增:页面刷新状态标志
|
||
bool _isVideoDialogOpen = false; // 控制视频弹窗显示
|
||
String _videoStreamUrl = "";
|
||
bool isStopWork = false; //是否点击过停止
|
||
bool isreceiveFirstCompletePoint = false;
|
||
Offset _videoPopupPos = const Offset(0, 100); // 弹窗初始位置
|
||
final GlobalKey _mapRepaintKey = GlobalKey();
|
||
// 用于存储当前页面的 Toast OverlayEntry
|
||
List<OverlayEntry> _toastEntries = [];
|
||
// Toast 队列:存储待显示的消息
|
||
final _toastQueue = <_ToastItem>[];
|
||
// 是否正在显示 Toast
|
||
bool _isToastShowing = false;
|
||
|
||
final MapController _mapController = MapController();
|
||
/// 全部场站高清叠加图层:初始用内置兜底,GetCapabilities 拉取成功后自动替换为动态范围
|
||
List<TileLayer> _stationLayers = kAllStationTileLayers;
|
||
// 初始化轨迹管理器(泛型指定为LatLng)
|
||
late final TracePoint<PlotPoint> _traceManager;
|
||
late String workMode = "覆盖模式"; // 作业模式:默认值为"覆盖模式"
|
||
|
||
WorkStatus _workStatus = WorkStatus.idle;
|
||
bool isStartWork = false; //是否开始作业
|
||
|
||
LatLng? _currentLatLng; //gc格式
|
||
LatLng? _currentWgsLatLng;
|
||
|
||
bool _isPanelOpen = false; // 控制面板显示/隐藏
|
||
bool _directionBoxOpen = false; //航线面板显示/隐藏
|
||
bool _saveBoxOpen = false; //保存按钮显示/隐藏
|
||
bool _isListBoxOpen = false; //列表面板显示/隐藏
|
||
bool _showHeadingWarn = false; // 航向角未初始化提示
|
||
bool _showControlModeWarn = false; // 远程模式提示
|
||
// 新增:存储选中的地块(null表示未选中)
|
||
PlotData? _selectedPlot;
|
||
// 新增:控制底部作业面板显示
|
||
bool _isWorkPanelOpen = false;
|
||
bool _isWorkAreaCompleted = false;
|
||
RobotMode _currentRobotMode = RobotMode.robot; //打点模式
|
||
AreaMode _currentAreaMode = AreaMode.work; //作业区域还是障碍物区域
|
||
WorkMode? _currentWorkMode = WorkMode.bow; // 当前作业模式
|
||
// 新增:存储转换后的path和outer坐标
|
||
List<LatLng> gcjPathPoints = [];
|
||
List<LatLng> gcjOuterPoints = [];
|
||
|
||
List<dynamic> startWorkList = [];
|
||
|
||
List<PlotPoint>? tracePoint = []; //从tracepoint中获取的经纬度坐标
|
||
List<PlotPoint>? gctracePoint = [];
|
||
|
||
// ========== 核心新增状态 ==========
|
||
List<LatLng> _markedPoints = []; // 存储所有s十字准星打点坐标
|
||
List<LatLng> _robotModeWgsPoints = []; // Robot模式下从设备获取的wgs打点
|
||
List<LatLng> _robotModeObsWgsPoints = []; // Robot模式下从设备获取的wgs打点 障碍物
|
||
|
||
LatLng _mapCenter = const LatLng(39.9042, 116.4074);
|
||
double _headingAngle = 0.0; // 当前机器航向角(单位:度)
|
||
List typedPathList =
|
||
<work_area_model.DeviceAddPathPointModel>[]; // 存储生成路径的坐标列表(已转换为LatLng)
|
||
|
||
// ========== 障碍物模式核心状态 ==========
|
||
List<List<LatLng>> _obstacleHoles = []; // 存储多组障碍物打点(嵌套数组:每组是一个障碍物)
|
||
|
||
List<List<LatLng>> _obstacleWgsHoles = []; // 存储多组障碍物打点(嵌套数组:每组是一个障碍物)
|
||
List<LatLng> _currentObstaclePoints = []; // 当前正在绘制的障碍物打点
|
||
bool _isObstacleEditing = false; // 是否处于障碍物编辑状态
|
||
|
||
//打点
|
||
double _workDistance = 0.0; // 作业行距(单位:米)
|
||
double _angle = 0.0; // 航线方向角(单位:度)
|
||
|
||
// 大疆地图
|
||
bool isDjMapShow = false;
|
||
late DJIStation _currentStation;
|
||
|
||
// 所有可用的场站列表(根据你的实际瓦片目录配置)
|
||
final List<DJIStation> _stationList = [
|
||
const DJIStation(name: 'liaoning', displayName: 'station_1'),
|
||
const DJIStation(name: 'station_b', displayName: 'station_2'),
|
||
const DJIStation(name: 'station_c', displayName: 'station_3'),
|
||
// 新增场站只需在这里添加,无需修改其他逻辑
|
||
];
|
||
|
||
// 其他
|
||
StreamSubscription? _pathPlanningSub;
|
||
StreamSubscription? _routeStatusSub;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
debugPrint('🚀 [MapPage] initState 开始');
|
||
_currentStation = _stationList.first;
|
||
_traceManager = TracePoint<PlotPoint>();
|
||
|
||
// 🔥 修复:异步加载数据,避免阻塞
|
||
_loadSavedData().catchError((e) {
|
||
debugPrint('❌ [MapPage] 加载本地数据失败: $e');
|
||
});
|
||
|
||
// 示例:切换到导航模式
|
||
_traceManager.setMode(TPMode.LOCATION);
|
||
tracePoint = [];
|
||
gctracePoint = [];
|
||
|
||
// 🔥 核心修复:使用 addPostFrameCallback 延迟获取地图中心(渲染完成后执行)
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
|
||
debugPrint('🗺️ [MapPage] addPostFrameCallback 执行');
|
||
if (_mapController.camera != null) {
|
||
final originalCenter = _mapController.camera!.center;
|
||
final highPrecisionCenter = LatLng(
|
||
originalCenter.latitude,
|
||
originalCenter.longitude,
|
||
);
|
||
|
||
setState(() {
|
||
_mapCenter = highPrecisionCenter;
|
||
});
|
||
} else {
|
||
// 兜底:使用默认坐标初始化地图中心
|
||
final defaultCenter = _getDefaultValidCenter();
|
||
setState(() {
|
||
_mapCenter = defaultCenter;
|
||
});
|
||
}
|
||
});
|
||
|
||
// 🔥 关键修复:开始监听路径规划指令应答
|
||
// 🔥 已禁用:新流程使用 HTTP/MQTT 管理任务,不再需要 TCP 路径规划指令应答监听
|
||
// 保留 TCP 监听会导致机器正常 TCP 0x01 响应触发 finishWork(),错误地将作业状态重置为 idle
|
||
// _setupPathPlanningListener();
|
||
|
||
// 监听地图移动事件,实时更新连线
|
||
_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;
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// 🌐 场站高清图层:监听动态范围更新 + 触发一次 GetCapabilities 刷新
|
||
StationLayerRepo.instance.addListener(_onStationLayersUpdated);
|
||
StationLayerRepo.instance.refresh();
|
||
|
||
// 🔄 进场刷新场站数据:避免 siteMapName/默认中心沿用上次缓存(无需退回首页重新请求接口)
|
||
sl<SiteCubit>().reloadSites();
|
||
|
||
debugPrint('✅ [MapPage] initState 完成');
|
||
}
|
||
|
||
/// GeoServer 动态范围拉取完成 → 用最新范围重建叠加图层
|
||
void _onStationLayersUpdated() {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_stationLayers = StationLayerRepo.instance.buildLayers();
|
||
});
|
||
}
|
||
|
||
// 🔥 核心修复:监听路径规划应答流
|
||
void _setupPathPlanningListener() {
|
||
debugPrint('🔗 [MapPage] 开始监听路径规划指令应答 (CMD: 0x01)');
|
||
|
||
_pathPlanningSub = _dispatcher.onPathPlanningResponse().listen(
|
||
(packet) {
|
||
debugPrint('✅ [MapPage] 收到路径规划应答,准备发送下一个点');
|
||
// 这里不需要做任何事情,因为 NetMessageDispatcher 内部已经处理了
|
||
// 它会自动从队列中移除已发送的点并触发下一个点
|
||
},
|
||
onDone: () => debugPrint('⚠️ [MapPage] 路径规划流已结束'),
|
||
onError: (e) => debugPrint('❌ [MapPage] 路径规划流错误:$e'),
|
||
);
|
||
}
|
||
|
||
// 新增:手动清除本地存储数据
|
||
Future<void> _clearLocalData() async {
|
||
try {
|
||
await LocationUtils.clearCache(LocationUtils.kSavedGcjPathPoints);
|
||
await LocationUtils.clearCache(LocationUtils.kSavedGcjOuterPoints);
|
||
await LocationUtils.clearCache(LocationUtils.kSavedMarkedPoints);
|
||
await LocationUtils.clearCache(LocationUtils.kSavedObstacleHoles);
|
||
await LocationUtils.clearCache(LocationUtils.kSavedIsWorkAreaCompleted);
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.remove(kSavedWorkStatus);
|
||
await prefs.remove(kSavedTPMode);
|
||
await prefs.remove(kSavedCurrentRobotMode); // 机器人模式
|
||
await prefs.remove("kSavedTypedPathList"); // 生成的路径
|
||
await prefs.remove(kSavedIsStartWork);
|
||
await prefs.remove(kSavedIsStopWork);
|
||
await prefs.remove(kSavedGcjPathPoints); // 路径点
|
||
await prefs.remove(kSavedGcjOuterPoints); // 外边框
|
||
await prefs.remove(kSavedMarkedPoints); // 打点
|
||
await prefs.remove(kSavedObstacleHoles); // 障碍物
|
||
await prefs.remove(kSavedIsWorkAreaCompleted);
|
||
await prefs.remove(kSavedIsWorkPanelOpen);
|
||
await prefs.remove(kSavedWorkMode);
|
||
await prefs.remove(kSavedStartWorkList);
|
||
await prefs.remove(kSavedSelectedPlot);
|
||
await prefs.remove(kSavedIsPanelOpen);
|
||
await prefs.remove(kSavedCurrentWorkMode);
|
||
await prefs.remove(kSavedDeviceTaskIds); // 🔥 清除 taskId 持久化数据
|
||
} catch (e) {
|
||
debugPrint('清空本地数据失败:$e');
|
||
}
|
||
}
|
||
|
||
// 新增:加载本地存储数据方法
|
||
Future<void> _loadSavedData() async {
|
||
try {
|
||
_traceManager.setMode(TPMode.LOCATION);
|
||
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final workStatusStr = prefs.getString(kSavedWorkStatus);
|
||
if (workStatusStr != null) {
|
||
_workStatus = WorkStatus.values.firstWhere(
|
||
(e) => e.toString() == workStatusStr,
|
||
orElse: () => WorkStatus.idle,
|
||
);
|
||
}
|
||
final modeStr = prefs.getString(kSavedTPMode);
|
||
if (modeStr != null) {
|
||
_traceManager.setMode(
|
||
TPMode.values.firstWhere((e) => e.toString() == modeStr),
|
||
);
|
||
}
|
||
|
||
final robotModeStr = prefs.getString(kSavedCurrentRobotMode);
|
||
if (robotModeStr != null) {
|
||
_currentRobotMode = RobotMode.values.firstWhere(
|
||
(e) => e.toString() == robotModeStr,
|
||
orElse: () => RobotMode.point,
|
||
);
|
||
}
|
||
|
||
// 读取 生成完成的路径 typedPathList
|
||
final typedPathString = prefs.getString("kSavedTypedPathList");
|
||
if (typedPathString != null) {
|
||
final List<dynamic> typedPathJson = jsonDecode(typedPathString);
|
||
setState(() {
|
||
typedPathList = typedPathJson.map((p) {
|
||
return work_area_model.DeviceAddPathPointModel(
|
||
latitude: p['lat'],
|
||
longitude: p['lng'],
|
||
);
|
||
}).toList();
|
||
});
|
||
}
|
||
|
||
// 2. 恢复是否开始作业
|
||
isStartWork = prefs.getBool(kSavedIsStartWork) ?? false;
|
||
|
||
// 3. 恢复是否点击过停止
|
||
isStopWork = prefs.getBool(kSavedIsStopWork) ?? false;
|
||
|
||
_isPanelOpen = prefs.getBool(kSavedIsPanelOpen) ?? false;
|
||
|
||
// 加载路径点
|
||
final pathJson = prefs.getString(kSavedGcjPathPoints);
|
||
if (pathJson != null) {
|
||
final List<dynamic> pathList = jsonDecode(pathJson);
|
||
gcjPathPoints = pathList
|
||
.map(
|
||
(item) => LatLng(
|
||
double.parse(item['lat'].toString()),
|
||
double.parse(item['lng'].toString()),
|
||
),
|
||
)
|
||
.toList();
|
||
}
|
||
|
||
// 加载外边界点
|
||
final outerJson = prefs.getString(kSavedGcjOuterPoints);
|
||
if (outerJson != null) {
|
||
final List<dynamic> outerList = jsonDecode(outerJson);
|
||
gcjOuterPoints = outerList
|
||
.map(
|
||
(item) => LatLng(
|
||
double.parse(item['lat'].toString()),
|
||
double.parse(item['lng'].toString()),
|
||
),
|
||
)
|
||
.toList();
|
||
}
|
||
|
||
// 加载打点数据
|
||
final markedJson = prefs.getString(kSavedMarkedPoints);
|
||
if (markedJson != null) {
|
||
final List<dynamic> markedList = jsonDecode(markedJson);
|
||
_markedPoints = markedList
|
||
.map(
|
||
(item) => LatLng(
|
||
double.parse(item['lat'].toString()),
|
||
double.parse(item['lng'].toString()),
|
||
),
|
||
)
|
||
.toList();
|
||
}
|
||
|
||
// 加载机器人模式WGS打点
|
||
final robotWgsJson = prefs.getString(kSavedRobotModeWgsPoints);
|
||
if (robotWgsJson != null) {
|
||
final List<dynamic> robotWgsList = jsonDecode(robotWgsJson);
|
||
_robotModeWgsPoints = robotWgsList
|
||
.map(
|
||
(item) => LatLng(
|
||
double.parse(item['lat'].toString()),
|
||
double.parse(item['lng'].toString()),
|
||
),
|
||
)
|
||
.toList();
|
||
}
|
||
|
||
// 加载障碍物数据
|
||
final obstacleJson = prefs.getString(kSavedObstacleHoles);
|
||
if (obstacleJson != null) {
|
||
final List<dynamic> obstacleList = jsonDecode(obstacleJson);
|
||
_obstacleHoles = obstacleList.map((hole) {
|
||
return (hole as List<dynamic>)
|
||
.map(
|
||
(item) => LatLng(
|
||
double.parse(item['lat'].toString()),
|
||
double.parse(item['lng'].toString()),
|
||
),
|
||
)
|
||
.toList();
|
||
}).toList();
|
||
}
|
||
|
||
// 加载作业区域完成状态
|
||
_isWorkAreaCompleted = prefs.getBool(kSavedIsWorkAreaCompleted) ?? false;
|
||
_saveBoxOpen = _isWorkAreaCompleted;
|
||
|
||
// 1. 恢复作业面板状态
|
||
_isWorkPanelOpen = prefs.getBool(kSavedIsWorkPanelOpen) ?? false;
|
||
|
||
// 2. 恢复作业模式
|
||
workMode = prefs.getString(kSavedWorkMode) ?? "覆盖模式";
|
||
// 读取
|
||
_currentWorkMode = prefs.getString(kSavedCurrentWorkMode) != null
|
||
? WorkMode.values.firstWhere(
|
||
(e) => e.toString() == prefs.getString(kSavedCurrentWorkMode),
|
||
orElse: () => WorkMode.bow,
|
||
)
|
||
: WorkMode.bow;
|
||
// 3. 恢复作业列表
|
||
final startWorkJson = prefs.getString(kSavedStartWorkList);
|
||
if (startWorkJson != null) {
|
||
startWorkList = jsonDecode(startWorkJson);
|
||
}
|
||
|
||
// 4. 恢复选中地块
|
||
final selectedPlotJson = prefs.getString(kSavedSelectedPlot);
|
||
if (selectedPlotJson != null && selectedPlotJson.isNotEmpty) {
|
||
final Map<String, dynamic> plotMap = jsonDecode(selectedPlotJson);
|
||
// 兼容 jsonData 为 Map 或 String
|
||
String? cachedJsonData;
|
||
final rawCached = plotMap['jsonData'];
|
||
if (rawCached is String) {
|
||
cachedJsonData = rawCached;
|
||
} else if (rawCached is Map) {
|
||
cachedJsonData = jsonEncode(rawCached);
|
||
}
|
||
|
||
_selectedPlot = PlotData(
|
||
id: plotMap['id'] ?? '',
|
||
plotName: plotMap['plotName'] ?? '',
|
||
imageUrl: plotMap['imageUrl'] ?? '',
|
||
jsonData: cachedJsonData,
|
||
);
|
||
}
|
||
|
||
// ==================== ✅ 核心修复 ====================
|
||
// 1. 从缓存恢复后,强制让 _markedPoints 同步给外边框(解决不显示)
|
||
if (_markedPoints.isNotEmpty && gcjOuterPoints.isEmpty) {
|
||
// 🔥 覆盖模式:兜底恢复时同样闭合边界,避免重启后 outer 缺一边
|
||
gcjOuterPoints = _closeBowRing(_markedPoints);
|
||
}
|
||
|
||
// 2. 关键:同步机器人模式坐标(保存时边框不丢失)
|
||
// ⚠️ 地图已切换为 WGS84,不再需要坐标转换
|
||
if (_currentRobotMode == RobotMode.point && _robotModeWgsPoints.isEmpty) {
|
||
_robotModeWgsPoints = List.from(_markedPoints); // 直接使用 WGS84 坐标
|
||
}
|
||
// 兜底:Robot模式下_robotModeWgsPoints缓存未写入时,从_markedPoints恢复
|
||
if (_currentRobotMode == RobotMode.robot &&
|
||
_robotModeWgsPoints.isEmpty &&
|
||
_markedPoints.isNotEmpty) {
|
||
_robotModeWgsPoints = List.from(_markedPoints); // 直接使用 WGS84 坐标
|
||
}
|
||
|
||
if (mounted) {
|
||
setState(() {});
|
||
// 移动地图到绘制内容中心
|
||
List<LatLng> allPoints = [];
|
||
allPoints.addAll(gcjPathPoints);
|
||
allPoints.addAll(gcjOuterPoints);
|
||
if (allPoints.isNotEmpty) {
|
||
moveMapToPointsCenter(allPoints);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
debugPrint('加载本地数据失败:$e');
|
||
}
|
||
}
|
||
|
||
// 新增:保存绘制数据到本地
|
||
Future<void> _saveDataToLocal() async {
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
|
||
// 保存路径点
|
||
final pathList = gcjPathPoints
|
||
.map((point) => {'lat': point.latitude, 'lng': point.longitude})
|
||
.toList();
|
||
prefs.setString(kSavedGcjPathPoints, jsonEncode(pathList));
|
||
|
||
// 保存外边界点
|
||
final outerList = gcjOuterPoints
|
||
.map((point) => {'lat': point.latitude, 'lng': point.longitude})
|
||
.toList();
|
||
prefs.setString(kSavedGcjOuterPoints, jsonEncode(outerList));
|
||
|
||
// 保存打点数据
|
||
final markedList = _markedPoints
|
||
.map((point) => {'lat': point.latitude, 'lng': point.longitude})
|
||
.toList();
|
||
prefs.setString(kSavedMarkedPoints, jsonEncode(markedList));
|
||
|
||
// 保存机器人模式WGS打点
|
||
final robotWgsList = _robotModeWgsPoints
|
||
.map((point) => {'lat': point.latitude, 'lng': point.longitude})
|
||
.toList();
|
||
prefs.setString(kSavedRobotModeWgsPoints, jsonEncode(robotWgsList));
|
||
|
||
// 保存障碍物数据
|
||
final obstacleList = _obstacleHoles.map((hole) {
|
||
return hole
|
||
.map((point) => {'lat': point.latitude, 'lng': point.longitude})
|
||
.toList();
|
||
}).toList();
|
||
prefs.setString(kSavedObstacleHoles, jsonEncode(obstacleList));
|
||
|
||
final typedPath = typedPathList
|
||
.map((point) => {'lat': point.latitude, 'lng': point.longitude})
|
||
.toList();
|
||
prefs.setString("kSavedTypedPathList", jsonEncode(typedPath));
|
||
|
||
// 保存作业区域完成状态
|
||
prefs.setBool(kSavedIsWorkAreaCompleted, _isWorkAreaCompleted);
|
||
|
||
prefs.setBool(kSavedIsPanelOpen, _isPanelOpen);
|
||
|
||
// 1. 保存作业面板显示状态
|
||
prefs.setBool(kSavedIsWorkPanelOpen, _isWorkPanelOpen);
|
||
|
||
// 2. 保存作业模式
|
||
prefs.setString(kSavedWorkMode, workMode);
|
||
|
||
// 3. 保存作业列表
|
||
prefs.setString(kSavedStartWorkList, jsonEncode(startWorkList));
|
||
prefs.setString(kSavedWorkStatus, _workStatus.toString());
|
||
|
||
// 2. 保存是否开始作业
|
||
prefs.setBool(kSavedIsStartWork, isStartWork);
|
||
|
||
// 3. 保存是否点击过停止
|
||
prefs.setBool(kSavedIsStopWork, isStopWork);
|
||
prefs.setString(kSavedCurrentWorkMode, _currentWorkMode.toString());
|
||
// 保存当前模式到本地缓存
|
||
prefs.setString(kSavedTPMode, _traceManager.getMode().toString());
|
||
|
||
prefs.setString(kSavedCurrentRobotMode, _currentRobotMode.toString());
|
||
|
||
// 4. 保存选中地块
|
||
if (_selectedPlot != null) {
|
||
final plotMap = {
|
||
'id': _selectedPlot!.id,
|
||
'plotName': _selectedPlot!.plotName,
|
||
'imageUrl': _selectedPlot!.imageUrl,
|
||
'jsonData': _selectedPlot!.jsonData,
|
||
};
|
||
prefs.setString(kSavedSelectedPlot, jsonEncode(plotMap));
|
||
} else {
|
||
prefs.remove(kSavedSelectedPlot);
|
||
}
|
||
|
||
// 🔥 5. 持久化 taskId(按 deviceId 隔离)
|
||
try {
|
||
final taskCubit = sl<DeviceTaskCubit>();
|
||
final taskId = taskCubit.state.currentTaskId;
|
||
final deviceId = context
|
||
.read<RemoteControlCubit>()
|
||
.state
|
||
.targetDevice
|
||
?.deviceName;
|
||
if (taskId != null && deviceId != null) {
|
||
final existingJson = prefs.getString(kSavedDeviceTaskIds);
|
||
Map<String, dynamic> taskIdMap = {};
|
||
if (existingJson != null) {
|
||
taskIdMap = jsonDecode(existingJson) as Map<String, dynamic>;
|
||
}
|
||
taskIdMap[deviceId] = taskId;
|
||
prefs.setString(kSavedDeviceTaskIds, jsonEncode(taskIdMap));
|
||
debugPrint('💾 [持久化] taskId 已保存: deviceId=$deviceId, taskId=$taskId');
|
||
} else {
|
||
debugPrint('💾 [持久化] 跳过: taskId=$taskId, deviceId=$deviceId');
|
||
}
|
||
} catch (e) {
|
||
debugPrint('💾 [持久化] taskId 保存失败: $e');
|
||
}
|
||
} catch (e) {
|
||
debugPrint('保存本地数据失败:$e');
|
||
}
|
||
}
|
||
|
||
/// 🔥 从 SharedPreferences 恢复指定设备的 taskId
|
||
Future<int?> _restoreTaskIdFromLocal(String deviceId) async {
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final json = prefs.getString(kSavedDeviceTaskIds);
|
||
if (json != null) {
|
||
final taskIdMap = jsonDecode(json) as Map<String, dynamic>;
|
||
final taskId = taskIdMap[deviceId];
|
||
if (taskId is int) return taskId;
|
||
if (taskId is String) return int.tryParse(taskId);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('📥 [恢复] taskId 恢复失败: $e');
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// 🔥 从 SharedPreferences 清除指定设备的 taskId
|
||
Future<void> _clearTaskIdFromLocal(String deviceId) async {
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final json = prefs.getString(kSavedDeviceTaskIds);
|
||
if (json != null) {
|
||
final taskIdMap = jsonDecode(json) as Map<String, dynamic>;
|
||
taskIdMap.remove(deviceId);
|
||
if (taskIdMap.isEmpty) {
|
||
await prefs.remove(kSavedDeviceTaskIds);
|
||
} else {
|
||
await prefs.setString(kSavedDeviceTaskIds, jsonEncode(taskIdMap));
|
||
}
|
||
debugPrint('🧹 [清除] taskId 已从本地移除: deviceId=$deviceId');
|
||
}
|
||
} catch (e) {
|
||
debugPrint('🧹 [清除] taskId 清除失败: $e');
|
||
}
|
||
}
|
||
|
||
// ========== 新增:计算坐标列表的边界范围 ==========
|
||
LatLngBounds? calculateBounds(List<LatLng> points) {
|
||
if (points.isEmpty) return null;
|
||
|
||
// 使用静态方法 fromPoints 创建边界(所有latlong2版本都支持)
|
||
return LatLngBounds.fromPoints(points);
|
||
}
|
||
|
||
void moveMapToPointsCenter(List<LatLng> points) {
|
||
debugPrint('🗺️ [移动地图] 开始: 点数=${points.length}, mounted=$mounted');
|
||
if (points.isEmpty || !mounted) {
|
||
debugPrint('🗺️ [移动地图] 跳过: points为空=${points.isEmpty}, mounted=$mounted');
|
||
return;
|
||
}
|
||
|
||
// 计算边界
|
||
final bounds = calculateBounds(points);
|
||
if (bounds == null) {
|
||
debugPrint('🗺️ [移动地图] 跳过: bounds 计算为 null');
|
||
return;
|
||
}
|
||
final currentZoom = _mapController.camera?.zoom ?? 18.0;
|
||
debugPrint('🗺️ [移动地图] 目标中心: ${bounds.center}, 当前缩放: $currentZoom');
|
||
debugPrint(
|
||
'🗺️ [移动地图] bounds: southWest=${bounds.southWest}, northEast=${bounds.northEast}',
|
||
);
|
||
_mapController.move(
|
||
bounds.center, // 边界中心点
|
||
currentZoom, // 保留当前缩放比例
|
||
);
|
||
|
||
debugPrint('🗺️ [移动地图] 移动完成, 地图当前中心: ${_mapController.camera?.center}');
|
||
}
|
||
|
||
// ========== 新增:安全转换任意类型为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(LatLng? latLng) {
|
||
if (latLng == null) return false;
|
||
// 排除赤道0,0坐标,同时校验经纬度范围(避免非法值)
|
||
return latLng.latitude != 0.0 &&
|
||
latLng.longitude != 0.0 &&
|
||
latLng.latitude >= -90 &&
|
||
latLng.latitude <= 90 &&
|
||
latLng.longitude >= -180 &&
|
||
latLng.longitude <= 180;
|
||
}
|
||
|
||
bool _isValidLatLng(double lat, double lng) {
|
||
return lat != 0 &&
|
||
lng != 0 &&
|
||
lat >= -90 &&
|
||
lat <= 90 &&
|
||
lng >= -180 &&
|
||
lng <= 180;
|
||
}
|
||
|
||
void _onDeviceData(
|
||
double lng,
|
||
double lat,
|
||
bool obfFlag,
|
||
int headingStatus,
|
||
dynamic controlMode,
|
||
) {
|
||
try {
|
||
// 1. 校验坐标是否有效
|
||
bool isValid = _isValidLatLng(lat, lng);
|
||
|
||
// ==============================================
|
||
// 🔥 关键:无效坐标只打印,不 return,不断流
|
||
// ==============================================
|
||
if (!isValid) {
|
||
debugPrint('[定位] 坐标无效:lat=$lat, lng=$lng → 跳过本次定位更新');
|
||
// 不写 return!!!
|
||
}
|
||
|
||
// ==============================================
|
||
// 2. 航向角提示(不受坐标影响,必须执行)
|
||
// 🔥 航向角拦截暂时注释,后续恢复
|
||
// ==============================================
|
||
if (isStartWork && !isStopWork) {
|
||
// if (headingStatus == 0) {
|
||
// if (!_showHeadingWarn) {
|
||
// if (mounted) {
|
||
// setState(() {
|
||
// _showHeadingWarn = true;
|
||
// });
|
||
// }
|
||
// }
|
||
// } else {
|
||
// if (_showHeadingWarn && mounted) {
|
||
// setState(() {
|
||
// _showHeadingWarn = false;
|
||
// });
|
||
// }
|
||
// }
|
||
|
||
// ==============================================
|
||
// 3. 远程模式提示(已关闭,用户要求去掉)
|
||
// ==============================================
|
||
// String mode = controlMode?.toString() ?? "";
|
||
// if (mode != "3") {
|
||
// if (!_showControlModeWarn && mounted) {
|
||
// setState(() {
|
||
// _showControlModeWarn = true;
|
||
// });
|
||
// }
|
||
// } else {
|
||
// if (_showControlModeWarn && mounted) {
|
||
// setState(() {
|
||
// _showControlModeWarn = false;
|
||
// });
|
||
// }
|
||
// }
|
||
} else {
|
||
// 未作业 → 自动隐藏所有警告
|
||
if ((_showHeadingWarn || _showControlModeWarn) && mounted) {
|
||
setState(() {
|
||
_showHeadingWarn = false;
|
||
_showControlModeWarn = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
// ==============================================
|
||
// 4. 障碍物提示(可选,你自己开关)
|
||
// ==============================================
|
||
// if (isStartWork && obfFlag) {
|
||
// ObsToastWidget.show(context: context, message: "小迈提醒您前方有障碍物哦!");
|
||
// } else {
|
||
// ObsToastWidget.dismiss();
|
||
// }
|
||
} catch (e) {
|
||
debugPrint('[定位] 处理设备数据异常:$e');
|
||
}
|
||
}
|
||
|
||
void _showSavePlotDialog() {
|
||
final loc = AppLocalizations.of(context);
|
||
showInputConfirmDialog(
|
||
context: context,
|
||
title: loc.translate('route_planning.save_plot'), // 自定义标题
|
||
hintText: loc.translate('route_planning.plot_name_hint'), // 自定义输入提示
|
||
labelText: loc.translate('route_planning.plot_name_label'), // 自定义输入框标签
|
||
confirmText: loc.translate('route_planning.save'), // 确认按钮文字
|
||
cancelText: loc.translate('route_planning.cancel'), // 取消按钮文字
|
||
// 输入校验器(可选)
|
||
inputValidator: (inputText) {
|
||
if (inputText.isEmpty) {
|
||
return loc.translate('route_planning.plot_name_empty');
|
||
}
|
||
if (inputText.length > 20) {
|
||
return loc.translate('route_planning.plot_name_too_long');
|
||
}
|
||
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);
|
||
_showPageToast(
|
||
message:
|
||
loc.translate('route_planning.save_success') +
|
||
"," +
|
||
"但地图截图生成失败!",
|
||
type: ToastType.warn,
|
||
);
|
||
|
||
//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) {
|
||
// 特殊处理:如果是 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)',
|
||
);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 🔥 保存地块:打印即将 POST 给后端的完整请求详情
|
||
/// 用途:验证覆盖模式下 jsonData.outer / jsonData.path 是否已首尾闭合(A B C → A B C A)
|
||
/// ⚠️ 只读取已构造好的 Dart 对象和 MultipartFile 的元信息(field/filename/contentType/length),
|
||
/// 绝不调用 MultipartFile.finalize()——那会消费 ByteStream,导致后续 request.send() 失败。
|
||
/// (旁边的 _debugPrintMultipartRequest 正是踩了这个坑,故未启用)
|
||
void _logSaveRequestDetail({
|
||
required http.MultipartRequest request,
|
||
required Map<String, dynamic> savePath,
|
||
required String workRecordJson,
|
||
required String plotName,
|
||
required Object? siteId,
|
||
Object? orgId,
|
||
String? mode,
|
||
}) {
|
||
// debugPrint 单条过长会被 logcat 截断,长文本统一按行拆开输出
|
||
void printLines(String text) {
|
||
for (final line in text.split('\n')) {
|
||
debugPrint(line);
|
||
}
|
||
}
|
||
|
||
debugPrint('📤 [保存地块-请求详情] ================ BEGIN ================');
|
||
debugPrint('📤 [请求行] ${request.method} ${request.url}');
|
||
|
||
// 请求头:Authorization 脱敏,避免 token 明文落日志
|
||
final Map<String, String> safeHeaders = Map<String, String>.from(
|
||
request.headers,
|
||
);
|
||
final String? auth = safeHeaders['Authorization'];
|
||
if (auth != null) {
|
||
safeHeaders['Authorization'] = auth.length > 18
|
||
? '${auth.substring(0, 14)}...${auth.substring(auth.length - 4)} (已脱敏, 原长${auth.length})'
|
||
: '(已脱敏)';
|
||
}
|
||
debugPrint('📤 [请求头] $safeHeaders');
|
||
|
||
debugPrint(
|
||
'📤 [上下文] workName=$plotName, siteId=$siteId, orgId=$orgId, mode=$mode, '
|
||
'workMode=$_currentWorkMode, robotMode=$_currentRobotMode, '
|
||
'areaMode=$_currentAreaMode, planModel=${savePath['planModel']}',
|
||
);
|
||
|
||
// 🔥 mode / planModel 一致性自检:两者必须由同一个 _currentWorkMode 推导出来
|
||
final String expectMode = savePath['planModel'] == 0
|
||
? WorkMode.bow.modeCode
|
||
: WorkMode.custom.modeCode;
|
||
debugPrint(
|
||
'📤 [模式校验] planModel=${savePath['planModel']} → 期望 mode="$expectMode",'
|
||
'实际 mode="$mode" → ${expectMode == mode ? "✅ 一致" : "❌ 不一致,检查 _currentWorkMode"}',
|
||
);
|
||
debugPrint(
|
||
'📤 [场站校验] siteId=$siteId, orgId=$orgId → '
|
||
'${orgId != null ? "✅ orgId 已带上" : "⚠️ orgId 为 null,未写入请求体(selectedSite 为空?)"}',
|
||
);
|
||
|
||
// multipart 各 part 的元信息(不消费流)
|
||
debugPrint('📤 [multipart] part 数量: ${request.files.length}');
|
||
for (var i = 0; i < request.files.length; i++) {
|
||
final http.MultipartFile f = request.files[i];
|
||
debugPrint(
|
||
'📤 [multipart] part[$i] field=${f.field}, '
|
||
'filename="${f.filename ?? ''}", contentType=${f.contentType}, '
|
||
'大小=${f.length} 字节 (${(f.length / 1024).toStringAsFixed(1)} KB)',
|
||
);
|
||
}
|
||
|
||
// 逐点打印 outer / path,并标注首尾,肉眼即可确认是否闭合
|
||
void printPoints(String field) {
|
||
final List<dynamic> list = (savePath[field] as List?) ?? <dynamic>[];
|
||
debugPrint('📤 [jsonData.$field] 点数=${list.length}');
|
||
for (var i = 0; i < list.length; i++) {
|
||
final Map<dynamic, dynamic> p = list[i] as Map;
|
||
final String tag = list.length == 1
|
||
? ' ← 唯一的点'
|
||
: (i == 0
|
||
? ' ← 第一个点'
|
||
: (i == list.length - 1 ? ' ← 最后一个点' : ''));
|
||
debugPrint(
|
||
'📤 [jsonData.$field] [$i] lat=${p['lat']}, lng=${p['lng']}$tag',
|
||
);
|
||
}
|
||
}
|
||
|
||
printPoints('outer');
|
||
printPoints('path');
|
||
|
||
// 闭环校验结论
|
||
bool isClosed(String field) {
|
||
final List<dynamic> list = (savePath[field] as List?) ?? <dynamic>[];
|
||
if (list.length < 4) return false;
|
||
final Map<dynamic, dynamic> first = list.first as Map;
|
||
final Map<dynamic, dynamic> last = list.last as Map;
|
||
return first['lat'] == last['lat'] && first['lng'] == last['lng'];
|
||
}
|
||
|
||
// 🔥 首尾并排比对:把 [0] 和 [最后一个] 的实际坐标上下两行打出来,
|
||
// 直接肉眼确认“第一个点被当作最后一个点追加到了末尾”
|
||
void printHeadTail(String field) {
|
||
final List<dynamic> list = (savePath[field] as List?) ?? <dynamic>[];
|
||
if (list.isEmpty) {
|
||
debugPrint('🔒 [首尾比对] $field:空数组,无比对对象');
|
||
return;
|
||
}
|
||
if (list.length == 1) {
|
||
debugPrint('🔒 [首尾比对] $field:只有 1 个点,不构成环');
|
||
return;
|
||
}
|
||
final Map<dynamic, dynamic> f = list.first as Map;
|
||
final Map<dynamic, dynamic> l = list.last as Map;
|
||
final int lastIdx = list.length - 1;
|
||
final bool same = f['lat'] == l['lat'] && f['lng'] == l['lng'];
|
||
debugPrint('🔒 [首尾比对] ---- $field (共 ${list.length} 个点) ----');
|
||
debugPrint('🔒 [首尾比对] $field[0] lat=${f['lat']}, lng=${f['lng']} ← 第一个点');
|
||
debugPrint('🔒 [首尾比对] $field[$lastIdx] lat=${l['lat']}, lng=${l['lng']} ← 最后一个点');
|
||
debugPrint(
|
||
'🔒 [首尾比对] 结论:$field[$lastIdx] ${same ? "==" : "!="} $field[0] → '
|
||
'${same ? "✅ 最后一个点就是第一个点(已闭环 ABCA)" : "❌ 两点不同(未闭环 ABC)"}',
|
||
);
|
||
if (same) {
|
||
debugPrint(
|
||
'🔒 [首尾比对] 即:用户实际打了 $lastIdx 个不同顶点,'
|
||
'末尾第 $lastIdx 位是自动追加的闭合点(与[0]完全相同)',
|
||
);
|
||
}
|
||
}
|
||
|
||
if (_currentWorkMode == WorkMode.bow) {
|
||
printHeadTail('outer');
|
||
printHeadTail('path');
|
||
|
||
final bool outerOk = isClosed('outer');
|
||
final bool pathOk = isClosed('path');
|
||
debugPrint(
|
||
'🔒 [闭环校验] 覆盖模式要求首尾同点 → '
|
||
'outer=${outerOk ? "✅已闭合" : "❌未闭合"}, '
|
||
'path=${pathOk ? "✅已闭合" : "❌未闭合"}',
|
||
);
|
||
if (!outerOk || !pathOk) {
|
||
debugPrint(
|
||
'🔒 [闭环校验] ⚠️ 未闭合原因排查:打点数=${_markedPoints.length}(需≥3), '
|
||
'robotMode打点数=${_robotModeWgsPoints.length}, '
|
||
'workMode=$_currentWorkMode(需为 WorkMode.bow)'
|
||
'${outerOk || pathOk ? "" : ";若两者都❌,说明 _closeBowRing 未执行追加,往上翻 🔗 [闭合] 日志看跳过原因"}',
|
||
);
|
||
}
|
||
} else {
|
||
debugPrint(
|
||
'🔒 [闭环校验] 当前 workMode=$_currentWorkMode 非覆盖模式,'
|
||
'outer 应为空、path 不闭合(符合预期)',
|
||
);
|
||
}
|
||
|
||
// 完整 JSON:这就是后端 /iot/workRecord/add 收到的 workRecord 内容
|
||
debugPrint('📤 [workRecord 完整JSON] ↓↓↓ 后端实际接收内容 ↓↓↓');
|
||
try {
|
||
printLines(
|
||
const JsonEncoder.withIndent(' ').convert(jsonDecode(workRecordJson)),
|
||
);
|
||
} catch (e) {
|
||
debugPrint('📤 [workRecord 完整JSON] 格式化失败($e),输出原始串:');
|
||
printLines(workRecordJson);
|
||
}
|
||
debugPrint('📤 [保存地块-请求详情] ================= END =================');
|
||
}
|
||
|
||
Future<void> _savePlotData(String plotName, String? imgBase64) async {
|
||
debugPrint('🔍 [保存地块] ====== _savePlotData 被调用, plotName=$plotName ======');
|
||
final loc = AppLocalizations.of(context);
|
||
// 🔥 V2 适配:使用 SiteCubit 获取站点ID(从 v2 首页场站选择获取,有默认值)
|
||
// selectedSite 就是各页面左上角下拉(SiteSelectorWidget)全局维护的那个场站
|
||
final selectedSite = sl<SiteCubit>().state.selectedSite;
|
||
final siteId = selectedSite?.id;
|
||
if (siteId == null) {
|
||
debugPrint('❌ [保存地块] siteId为空,提前返回!');
|
||
_showPageToast(message: '请先在场站列表中选择一个场站!', type: ToastType.error);
|
||
return;
|
||
}
|
||
// 🔥 对齐 Web 端 orgId: currentStation?.orgId
|
||
// ⚠️ 必须和 siteId 同源(selectedSite),不要取 AppUserCubit 里登录用户的 orgId
|
||
final orgId = selectedSite?.orgId;
|
||
for (var i = 0; i < typedPathList.length; i++) {
|
||
debugPrint(typedPathList[i].toString());
|
||
}
|
||
// 2. 构造 SavePath 数据 (对齐 Web 端逻辑)
|
||
// 🔥 Web 端参考:
|
||
// 覆盖模式: {path: API返回路径, outer: 打点坐标, planModel: 0}
|
||
// 自定义模式: {path: 打点坐标, outer: [], planModel: 2}
|
||
|
||
// 辅助方法:将打点转为 WGS84 格式 {lng, lat}
|
||
List<Map<String, double>> _markedPointsToWgs() {
|
||
final List<LatLng> source = _currentRobotMode == RobotMode.robot
|
||
? _robotModeWgsPoints
|
||
: _markedPoints;
|
||
// 🔥 覆盖模式:上传前闭合边界,首尾同点(A B C → A B C A)
|
||
// 下位机与后端要求第一个点与最后一个点相同
|
||
return _closeBowRing(source).map((point) {
|
||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||
return {'lng': point.longitude, 'lat': point.latitude};
|
||
}).toList();
|
||
}
|
||
|
||
final Map<String, dynamic> savePath = {
|
||
//'img': imgBase64 ?? '', // 截图的 Base64 字符串
|
||
// 'name': plotName, // 旧逻辑:Web 端不包含 name
|
||
// 🔥 对齐 Web 端:覆盖模式 outer=打点,自定义模式 outer=[]
|
||
// 旧逻辑:outer 始终取 gcjOuterPoints
|
||
'outer': _currentWorkMode == WorkMode.custom
|
||
? [] // 自定义模式 outer 为空
|
||
: _markedPointsToWgs(), // 覆盖模式 outer 为打点坐标
|
||
// 🔥 统一使用用户手动打点的边界点,不再上传算法清扫轨迹点
|
||
'path': _markedPointsToWgs(),
|
||
'planModel': _currentWorkMode == WorkMode.bow ? 0 : 2, // 作业模式值
|
||
};
|
||
|
||
// 3. 构造 workRecord (包裹一层)
|
||
final Map<String, dynamic> workRecord = {
|
||
'workName': plotName,
|
||
'siteId': siteId, // 修改:使用 siteId 代替 userId
|
||
// 🔥 jsonData 必须是对象,不能是字符串(后端 PlanPath 类型要求)
|
||
'jsonData': savePath,
|
||
};
|
||
// 🔥 对齐 Web 端 orgId(场站所属机构),与 siteId 同源于 SiteCubit.selectedSite
|
||
// Web 是 currentStation?.orgId,为 undefined 时 JSON.stringify 会丢掉该键,这里同样只在非空时加
|
||
if (orgId != null) {
|
||
workRecord['orgId'] = orgId;
|
||
}
|
||
// 🔥 对齐 Web 端 mode 字段(Web 注释"区域还是单点")
|
||
// 覆盖模式 → "coverage",自定义模式 → "single_point"
|
||
// ⚠️ 判定表达式必须和上面 planModel 用同一个,保证两字段永远同源不打架
|
||
final String modeCode = _currentWorkMode == WorkMode.bow
|
||
? WorkMode.bow.modeCode
|
||
: WorkMode.custom.modeCode;
|
||
workRecord['mode'] = modeCode;
|
||
debugPrint('📤 [保存地块] workRecord 附加字段: orgId=$orgId, mode=$modeCode');
|
||
final String workRecordJson = jsonEncode(workRecord);
|
||
final http.MultipartRequest request = http.MultipartRequest(
|
||
'POST',
|
||
Uri.parse('${HttpApiConsts.baseUrl}/iot/workRecord/add'),
|
||
);
|
||
|
||
// 🔥 添加 Authorization 认证头
|
||
final userToken = sl<AppUserCubit>().state.user?.token;
|
||
if (userToken != null) {
|
||
request.headers['Authorization'] = 'Bearer $userToken';
|
||
debugPrint('📤 [保存地块] Authorization 已设置');
|
||
} else {
|
||
debugPrint('⚠️ [保存地块] Token为空,请求可能被拒绝');
|
||
}
|
||
|
||
// 5. 处理文件:将 Base64 转为 MultipartFile (对应 JS 的 dataURLtoFile)
|
||
// 🔥 后端要求 file 字段必须存在,即使没有截图也要传
|
||
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'),
|
||
),
|
||
);
|
||
} else {
|
||
// 🔥 没有截图时,也要传一个空的 image/jpeg 文件,满足后端要求
|
||
final emptyImageBytes = <int>[]; // 空字节数组
|
||
request.files.add(
|
||
http.MultipartFile.fromBytes(
|
||
'file',
|
||
emptyImageBytes,
|
||
filename: 'empty.jpg',
|
||
contentType: http.MediaType('image', 'jpeg'),
|
||
),
|
||
);
|
||
}
|
||
|
||
// 6. 添加 workRecord (对应 formData.append("workRecord", Blob))
|
||
// 🔥 必须用 fromBytes 包装成文件,匹配前端 new Blob([JSON.stringify(workRecord)], {type: "application/json"})
|
||
final workRecordBytes = utf8.encode(workRecordJson);
|
||
request.files.add(
|
||
http.MultipartFile.fromBytes(
|
||
'workRecord',
|
||
workRecordBytes,
|
||
filename: '', // 关键:和浏览器 Blob 保持一致,文件名填空字符串
|
||
contentType: http.MediaType('application', 'json'),
|
||
),
|
||
);
|
||
|
||
debugPrint('🔍 [保存地块] 构造请求完成,即将进入try发送...');
|
||
|
||
// 🔥 发送前打印完整请求详情:URL、请求头、multipart 各 part、
|
||
// outer/path 逐点坐标(标注首尾)、闭环校验结论、workRecord 完整 JSON
|
||
_logSaveRequestDetail(
|
||
request: request,
|
||
savePath: savePath,
|
||
workRecordJson: workRecordJson,
|
||
plotName: plotName,
|
||
siteId: siteId,
|
||
orgId: orgId,
|
||
mode: modeCode,
|
||
);
|
||
|
||
// 7. 发送请求
|
||
try {
|
||
_showPageToast(
|
||
message: loc.translate('route_planning.saving') + "「$plotName」...",
|
||
type: ToastType.loading,
|
||
);
|
||
|
||
//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('📥 [保存地块] 响应状态码: ${response.statusCode}');
|
||
debugPrint('📥 [保存地块] 响应体(完整): $responseBody');
|
||
|
||
if (response.statusCode == 200) {
|
||
// 🔥 解析响应体确认后端是否真的成功
|
||
Map<String, dynamic>? respJson;
|
||
try {
|
||
respJson = jsonDecode(responseBody) as Map<String, dynamic>;
|
||
} catch (_) {
|
||
debugPrint('❌ [保存地块] 响应体非JSON: $responseBody');
|
||
throw Exception('响应体非JSON: $responseBody');
|
||
}
|
||
|
||
final apiCode = respJson['code'];
|
||
if (apiCode != null && apiCode.toString() != '200') {
|
||
final apiMsg = respJson['msg'] ?? respJson['message'] ?? '未知错误';
|
||
debugPrint('❌ [保存地块] 后端返回失败, code=$apiCode, msg=$apiMsg');
|
||
throw Exception('$apiMsg');
|
||
}
|
||
|
||
debugPrint('✅ [保存地块] 保存成功, plotName=$plotName, 响应: $respJson');
|
||
// 🔥 刷新地块列表
|
||
debugPrint('🔄 [保存地块] 刷新地块列表, siteId=$siteId');
|
||
context.read<DevicesCubit>().loadWorkRecordsBySiteId(siteId);
|
||
_showPageToast(
|
||
message: loc.translate('route_planning.save_success') + "「$plotName」",
|
||
type: ToastType.success,
|
||
);
|
||
|
||
//ToastUtils.showSuccess(context, '地块「$plotName」保存成功!');
|
||
_clearLocalData();
|
||
//ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('地块「$plotName」保存成功!'), backgroundColor: Colors.green));
|
||
// 保存成功
|
||
setState(() {
|
||
_markedPoints.clear(); // 清空打点
|
||
gcjPathPoints.clear(); // 清空路径
|
||
gcjOuterPoints.clear(); // 清空外边界
|
||
_robotModeWgsPoints.clear();
|
||
_obstacleHoles.clear();
|
||
|
||
_isPanelOpen = false; // 关闭操作面板
|
||
_directionBoxOpen = false;
|
||
_isWorkAreaCompleted = false; // 重置作业区域完成状态
|
||
});
|
||
} else {
|
||
throw Exception('服务器错误: ${response.statusCode}, $responseBody');
|
||
}
|
||
} catch (e) {
|
||
debugPrint('保存失败: $e');
|
||
_showPageToast(
|
||
message:
|
||
loc.translate('route_planning.save_failed') + ": ${e.toString()}",
|
||
type: ToastType.error,
|
||
);
|
||
|
||
//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 _undoAction() {
|
||
debugPrint(
|
||
'🔄 [撤回] 当前区域模式: $_currentAreaMode, 作业点数: ${_markedPoints.length}, 障碍物点数: ${_currentObstaclePoints.length}, 已完成空洞: ${_obstacleHoles.length}',
|
||
);
|
||
setState(() {
|
||
// 1. 作业区域模式
|
||
if (_currentAreaMode == AreaMode.work) {
|
||
if (_markedPoints.isNotEmpty) {
|
||
// 单独判断,单独删除
|
||
if (_robotModeWgsPoints.isNotEmpty) {
|
||
_robotModeWgsPoints.removeLast();
|
||
}
|
||
if (_markedPoints.isNotEmpty) {
|
||
_markedPoints.removeLast();
|
||
}
|
||
|
||
// 作业点撤回后,若路径已生成,重新生成路径(保持同步)
|
||
if (_isWorkAreaCompleted && _markedPoints.length >= 3) {
|
||
_generatePath(showTips: false);
|
||
}
|
||
_showPageToast(
|
||
message: "已撤回作业区域最后一个点,剩余${_markedPoints.length}个点",
|
||
type: ToastType.info,
|
||
);
|
||
} else {
|
||
final loc = AppLocalizations.of(context);
|
||
_showPageToast(
|
||
message: loc.translate('route_planning.no_work_points'),
|
||
type: ToastType.info,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 2. 空洞模式(完成后优先撤回已完成的空洞)
|
||
if (_currentAreaMode == AreaMode.obstacle) {
|
||
if (_currentObstaclePoints.isNotEmpty) {
|
||
// 单独判断,单独删除
|
||
if (_currentObstaclePoints.isNotEmpty) {
|
||
_currentObstaclePoints.removeLast();
|
||
}
|
||
if (_robotModeObsWgsPoints.isNotEmpty) {
|
||
_robotModeObsWgsPoints.removeLast();
|
||
}
|
||
|
||
if (_currentObstaclePoints.isEmpty) {
|
||
_isObstacleEditing = false;
|
||
}
|
||
_showPageToast(
|
||
message: "已撤回当前空洞最后一个点,剩余${_currentObstaclePoints.length}个点",
|
||
type: ToastType.info,
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 第二步:当前空洞无点可撤 → 再撤回「上一个完整空洞」
|
||
if (_obstacleHoles.isNotEmpty) {
|
||
_obstacleHoles.removeLast();
|
||
// 撤完空洞后重新生成路径(排除该空洞)
|
||
if (_isWorkAreaCompleted) {
|
||
_generatePath(showTips: false);
|
||
}
|
||
_showPageToast(
|
||
message: "已撤回上一个完整空洞,剩余${_obstacleHoles.length}个空洞",
|
||
type: ToastType.info,
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 第三步:无任何可撤内容
|
||
_showPageToast(message: "暂无空洞可撤回", type: ToastType.info);
|
||
}
|
||
});
|
||
_saveDataToLocal();
|
||
}
|
||
|
||
// ========== 统一删除方法(适配完成后状态) ==========
|
||
void _deleteAllAction() {
|
||
debugPrint(
|
||
'🗑️ [删除全部] 当前区域模式: $_currentAreaMode, 作业点数: ${_markedPoints.length}, 障碍物点数: ${_currentObstaclePoints.length}, 已完成空洞: ${_obstacleHoles.length}',
|
||
);
|
||
setState(() {
|
||
// 1. 作业区域模式:清空作业点 + 重置路径
|
||
if (_currentAreaMode == AreaMode.work) {
|
||
if (_markedPoints.isEmpty) {
|
||
_showPageToast(message: "暂无作业区域点可删除", type: ToastType.info);
|
||
|
||
//ToastUtils.showInfo(context, '暂无作业区域点可删除');
|
||
|
||
//ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无作业区域点可删除')));
|
||
return;
|
||
}
|
||
_markedPoints.clear();
|
||
gcjPathPoints.clear();
|
||
gcjOuterPoints.clear();
|
||
_robotModeWgsPoints.clear();
|
||
_obstacleHoles.clear();
|
||
_isWorkAreaCompleted = false;
|
||
_saveBoxOpen = false;
|
||
typedPathList.clear();
|
||
final loc = AppLocalizations.of(context);
|
||
_showPageToast(
|
||
message: loc.translate('route_planning.clear_work_area'),
|
||
type: ToastType.success,
|
||
);
|
||
//ToastUtils.showSuccess(context, '已清空所有作业区域点和路径');
|
||
|
||
//ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已清空所有作业区域点和路径')));
|
||
}
|
||
// 2. 空洞模式:清空所有空洞 + 恢复UI + 重新生成路径
|
||
else if (_currentAreaMode == AreaMode.obstacle) {
|
||
if (_obstacleHoles.isEmpty && _currentObstaclePoints.isEmpty) {
|
||
final loc = AppLocalizations.of(context);
|
||
_showPageToast(
|
||
message: loc.translate('route_planning.no_obstacles'),
|
||
type: ToastType.info,
|
||
);
|
||
|
||
//ToastUtils.showInfo(context, '暂无障碍物可删除');
|
||
return;
|
||
}
|
||
_obstacleHoles.clear();
|
||
_currentObstaclePoints.clear();
|
||
_robotModeObsWgsPoints.clear();
|
||
_isObstacleEditing = false;
|
||
// 清空后重新生成路径(无空洞)
|
||
if (_isWorkAreaCompleted) {
|
||
_generatePath(showTips: false);
|
||
}
|
||
final loc = AppLocalizations.of(context);
|
||
_showPageToast(
|
||
message: loc.translate('route_planning.clear_obstacles'),
|
||
type: ToastType.success,
|
||
);
|
||
//ToastUtils.showSuccess(context, '已清空所有障碍物');
|
||
}
|
||
});
|
||
_saveDataToLocal();
|
||
}
|
||
|
||
void _handleGotoLocation() {
|
||
debugPrint('📍 [定位] 跳转到设备当前位置: $_currentLatLng');
|
||
_mapController.move(_currentLatLng!, 18);
|
||
}
|
||
|
||
/// ===============================
|
||
/// 企业级定位初始化(已修复坐标系)
|
||
Future<void> _initLocationEnterprise() async {
|
||
// 1. 设置轨迹模式为 LOCATION(核心:确保模式正确)
|
||
_traceManager.setMode(TPMode.LOCATION);
|
||
}
|
||
|
||
// ========== 新增:页面刷新初始化方法 ==========
|
||
Future<void> _handleRefresh() async {
|
||
debugPrint('🔄 [刷新] 开始重置页面状态...');
|
||
if (_workStatus != WorkStatus.idle) {
|
||
_stopWork();
|
||
}
|
||
|
||
_cancelAllToast();
|
||
if (_isRefreshing) return;
|
||
|
||
setState(() {
|
||
_isRefreshing = true;
|
||
});
|
||
|
||
try {
|
||
if (_currentLatLng != null &&
|
||
_isValidLatLng(_currentLatLng!.latitude, _currentLatLng!.longitude)) {
|
||
_mapController.move(_currentLatLng!, 18);
|
||
}
|
||
|
||
// ==========================================
|
||
// 🔥 1. 清空 所有本地缓存
|
||
// ==========================================
|
||
await _clearLocalData();
|
||
|
||
// ==========================================
|
||
// 🔥 2. 清空 所有内存状态(彻底重置)
|
||
// ==========================================
|
||
setState(() {
|
||
// 轨迹彻底清空(关键!)
|
||
tracePoint?.clear();
|
||
gctracePoint?.clear();
|
||
tracePoint = [];
|
||
gctracePoint = [];
|
||
|
||
// 所有绘制数据清空
|
||
_markedPoints.clear();
|
||
gcjPathPoints.clear();
|
||
gcjOuterPoints.clear();
|
||
_robotModeWgsPoints.clear();
|
||
_robotModeObsWgsPoints.clear();
|
||
_obstacleHoles.clear();
|
||
_currentObstaclePoints.clear();
|
||
|
||
// 路径清空
|
||
typedPathList.clear();
|
||
startWorkList.clear();
|
||
|
||
// 状态重置
|
||
_isWorkAreaCompleted = false;
|
||
_saveBoxOpen = false;
|
||
_isPanelOpen = false;
|
||
_directionBoxOpen = false;
|
||
_isListBoxOpen = false;
|
||
_isWorkPanelOpen = false;
|
||
_isVideoDialogOpen = false;
|
||
_isObstacleEditing = false;
|
||
_showControlModeWarn = false;
|
||
|
||
// 模式重置
|
||
_currentRobotMode = RobotMode.robot;
|
||
_currentAreaMode = AreaMode.work;
|
||
_currentWorkMode = WorkMode.bow;
|
||
|
||
// 作业状态重置
|
||
_workStatus = WorkStatus.idle;
|
||
isStartWork = false;
|
||
isStopWork = false;
|
||
isreceiveFirstCompletePoint = false;
|
||
_showHeadingWarn = false;
|
||
_isPanelOpen = false;
|
||
|
||
_directionBoxOpen = false;
|
||
_isListBoxOpen = false;
|
||
_isWorkPanelOpen = false;
|
||
_isVideoDialogOpen = false;
|
||
_isObstacleEditing = false;
|
||
_selectedPlot = null;
|
||
|
||
// 重置模式
|
||
_currentRobotMode = RobotMode.robot;
|
||
_currentAreaMode = AreaMode.work;
|
||
_currentWorkMode = WorkMode.bow;
|
||
|
||
// 重置参数
|
||
_workDistance = 0.0;
|
||
_angle = 0.0;
|
||
|
||
// 定位重置
|
||
_currentLatLng = null;
|
||
_currentWgsLatLng = null;
|
||
_mapCenter = _getDefaultValidCenter();
|
||
|
||
isDjMapShow = false;
|
||
_workStatus = WorkStatus.idle;
|
||
isStartWork = false;
|
||
isStopWork = false;
|
||
|
||
// ==========================================
|
||
// 🔥 🔥 🔥 核心修复:强制重置轨迹管理器 + 强制改为 LOCATION 模式
|
||
// ==========================================
|
||
_traceManager.reset();
|
||
_traceManager.setMode(TPMode.LOCATION);
|
||
});
|
||
|
||
// ==========================================
|
||
// 🔥 3. 保存空状态
|
||
// ==========================================
|
||
await _saveDataToLocal();
|
||
|
||
if (mounted) {
|
||
_showPageToast(
|
||
message: AppLocalizations.of(
|
||
context,
|
||
).translate('route_planning.clear_cache_success'),
|
||
type: ToastType.success,
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
_showPageToast(
|
||
message:
|
||
"${AppLocalizations.of(context).translate('route_planning.refresh_failed')}:${e.toString().substring(0, 50)}",
|
||
type: ToastType.error,
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isRefreshing = false;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 手动回到当前位置
|
||
void _moveToCurrentLocation() {
|
||
if (_currentLatLng == null) return;
|
||
debugPrint('📍 [定位] 移动到当前位置: $_currentLatLng');
|
||
_mapController.move(_currentLatLng!, 17);
|
||
}
|
||
|
||
/// 手动刷新高清场站图层(不动用户的作业/障碍物绘制数据):
|
||
/// ① 清 WMS 瓦片缓存(拿后端最新影像)② 重拉场站接口(更新 siteMapName 映射)
|
||
/// ③ 重拉 GetCapabilities(更新图层范围数组)④ 重建图层 ⑤ 定位到最新场站中心
|
||
Future<void> _onRefreshStationLayers() async {
|
||
debugPrint('🔄 [刷新图层] 开始手动刷新高清场站图层...');
|
||
_showPageToast(message: '正在刷新高清图层...', type: ToastType.loading);
|
||
try {
|
||
await CachedWmsImage.clearTileCache();
|
||
await sl<SiteCubit>().reloadSites();
|
||
await StationLayerRepo.instance.refresh();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_stationLayers = StationLayerRepo.instance.buildLayers();
|
||
});
|
||
final c = _getDefaultValidCenter();
|
||
_mapController.move(c, 18);
|
||
_cancelAllToast();
|
||
_showPageToast(message: '已刷新到最新图层', type: ToastType.success);
|
||
debugPrint('✅ [刷新图层] 完成,定位到 $c');
|
||
} catch (e) {
|
||
debugPrint('❌ [刷新图层] 失败: $e');
|
||
if (!mounted) return;
|
||
_cancelAllToast();
|
||
final msg = e.toString();
|
||
_showPageToast(
|
||
message: '刷新失败: ${msg.length > 50 ? msg.substring(0, 50) : msg}',
|
||
type: ToastType.error,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ========== 障碍物模式:完成当前障碍物绘制 ==========
|
||
void _completeObstacle() {
|
||
debugPrint(
|
||
'✅ [完成障碍物] 当前障碍物点数: ${_currentObstaclePoints.length}, 已有障碍物区域: ${_obstacleHoles.length}',
|
||
);
|
||
if (_currentObstaclePoints.length < 3) {
|
||
_showPageToast(message: "障碍物区域至少需要3个打点!", type: ToastType.error);
|
||
//ToastUtils.showError(context, '障碍物区域至少需要3个打点!');
|
||
return;
|
||
}
|
||
|
||
setState(() {
|
||
// 保存当前障碍物组(Robot模式下直接用WGS84坐标)
|
||
if (_currentRobotMode == RobotMode.robot &&
|
||
_robotModeObsWgsPoints.isNotEmpty) {
|
||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||
_obstacleHoles.add(List.from(_robotModeObsWgsPoints));
|
||
_obstacleWgsHoles.add(List.from(_robotModeObsWgsPoints));
|
||
} else {
|
||
_obstacleHoles.add(List.from(_currentObstaclePoints));
|
||
}
|
||
|
||
_isObstacleEditing = false; // 退出编辑状态
|
||
});
|
||
_saveDataToLocal();
|
||
_showPageToast(
|
||
message: "已添加第${_obstacleHoles.length}个障碍物区域",
|
||
type: ToastType.success,
|
||
);
|
||
//ToastUtils.showSuccess(context, '已添加第${_obstacleHoles.length}个障碍物区域');
|
||
}
|
||
|
||
// ========== 核心方法:打点逻辑 ==========
|
||
void _addMarkedPoint() {
|
||
debugPrint(
|
||
'📍 [打点] 机器人模式: $_currentRobotMode, 区域模式: $_currentAreaMode, 作业已完成: $_isWorkAreaCompleted, 当前作业点数: ${_markedPoints.length}',
|
||
);
|
||
_traceManager.reset();
|
||
if (_currentAreaMode == AreaMode.work && _isWorkAreaCompleted) {
|
||
_showPageToast(message: "作业区域已完成,无法继续添加打点!", type: ToastType.error);
|
||
//ToastUtils.showError(context, '作业区域已完成,无法继续添加打点!');
|
||
return;
|
||
}
|
||
setState(() {
|
||
if (_currentRobotMode == RobotMode.point) {
|
||
if (_currentAreaMode == AreaMode.work) {
|
||
_markedPoints.add(_mapCenter);
|
||
debugPrint(
|
||
'新增作业区域打点:第${_markedPoints.length}个点,经纬度:${_mapCenter.latitude}, ${_mapCenter.longitude}',
|
||
);
|
||
} else {
|
||
if (_markedPoints.isEmpty) {
|
||
_showPageToast(message: "请先完成作业区域打点!", type: ToastType.info);
|
||
return;
|
||
}
|
||
// 障碍物模式:添加到当前障碍物打点
|
||
_currentObstaclePoints.add(_mapCenter);
|
||
_isObstacleEditing = true; // 标记进入障碍物编辑状态
|
||
debugPrint(
|
||
'新增障碍物打点:第${_currentObstaclePoints.length}个点,经纬度:${_mapCenter.latitude}, ${_mapCenter.longitude}',
|
||
);
|
||
}
|
||
} else {
|
||
if (_currentLatLng == null ||
|
||
!_isValidLatLng(
|
||
_currentLatLng!.latitude,
|
||
_currentLatLng!.longitude,
|
||
)) {
|
||
_showPageToast(message: "设备坐标无效,无法添加打点!", type: ToastType.error);
|
||
//ToastUtils.showError(context, '设备坐标无效,无法添加打点!');
|
||
return;
|
||
}
|
||
|
||
// 2. Robot模式-作业区域打点
|
||
if (_currentAreaMode == AreaMode.work) {
|
||
_markedPoints.add(_currentLatLng!); // 从设备实时坐标取最后一个值
|
||
_robotModeWgsPoints.add(_currentWgsLatLng!);
|
||
debugPrint(
|
||
'Robot模式新增作业打点:第${_markedPoints.length}个点,经纬度:${_currentLatLng!.latitude}, ${_currentLatLng!.longitude}',
|
||
);
|
||
} else {
|
||
if (_markedPoints.isEmpty || _robotModeWgsPoints.isEmpty) {
|
||
_showPageToast(message: "请先完成作业区域打点!", type: ToastType.info);
|
||
return;
|
||
}
|
||
// 3. Robot模式-障碍物打点
|
||
_currentObstaclePoints.add(_currentLatLng!);
|
||
_robotModeObsWgsPoints.add(_currentWgsLatLng!);
|
||
_isObstacleEditing = true;
|
||
debugPrint(
|
||
'Robot模式新增障碍物打点:第${_currentObstaclePoints.length}个点,经纬度:${_currentLatLng!.latitude}, ${_currentLatLng!.longitude}',
|
||
);
|
||
}
|
||
}
|
||
});
|
||
_saveDataToLocal();
|
||
debugPrint(
|
||
'$_markedPoints,新增打点:第${_markedPoints.length}个点,经纬度:${_mapCenter.latitude.toStringAsFixed(20)}, ${_mapCenter.longitude.toStringAsFixed(20)}',
|
||
);
|
||
}
|
||
|
||
// ========== 新增:返回上一级页面的方法 ==========
|
||
void _navigateBack() {
|
||
debugPrint('🔙 [返回] 关闭页面,返回上一级');
|
||
Navigator.of(context).pop();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
StationLayerRepo.instance.removeListener(_onStationLayersUpdated);
|
||
_traceManager.reset();
|
||
_mapController.dispose();
|
||
_pathPlanningSub?.cancel(); // 🔥 取消订阅
|
||
_routeStatusSub?.cancel();
|
||
_saveDataToLocal(); // 🔥 页面销毁时保存最终状态
|
||
_cancelAllToast(); // 退出页面 → 关闭所有本页面的Toast
|
||
super.dispose();
|
||
}
|
||
|
||
void _handleVideo() {
|
||
debugPrint('🎬 [视频] 打开视频弹窗');
|
||
setState(() {
|
||
_isVideoDialogOpen = true; // 打开视频弹窗
|
||
});
|
||
}
|
||
|
||
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: false,
|
||
showRightPip: false,
|
||
onClose: _closeVideoDialog, // 关闭回调
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _closeVideoDialog() {
|
||
setState(() {
|
||
_isVideoDialogOpen = false;
|
||
});
|
||
}
|
||
|
||
Widget _buildPlotListItem(PlotData plot, Function(PlotData) onDelete) {
|
||
return GestureDetector(
|
||
// 核心:点击列表项触发选中逻辑
|
||
onTap: () async {
|
||
debugPrint('📋 [路径规划] ====== 开始选中路线任务 ======');
|
||
debugPrint('📋 [路径规划] 选中路线任务:');
|
||
debugPrint(' ├─ 地块名称: ${plot.plotName}');
|
||
debugPrint(' ├─ 数据ID: ${plot.id}');
|
||
debugPrint(' └─ jsonData长度: ${plot.jsonData?.length ?? "null"}');
|
||
|
||
// 【优化1】第一步就UI响应,不卡手
|
||
setState(() {
|
||
_isListBoxOpen = false;
|
||
_isPanelOpen = false;
|
||
_directionBoxOpen = false;
|
||
gcjPathPoints = [];
|
||
gcjOuterPoints = [];
|
||
_selectedPlot = plot;
|
||
});
|
||
|
||
try {
|
||
_traceManager.reset();
|
||
|
||
// 🔥 核心修复:优先使用列表中已加载的 plot.jsonData,避免重复查询导致所有任务显示同一条路线
|
||
Map<String, dynamic>? parsedJson;
|
||
|
||
if (plot.jsonData != null && plot.jsonData!.isNotEmpty) {
|
||
debugPrint('✅ [选中地块] 尝试使用本地 jsonData,长度: ${plot.jsonData!.length}');
|
||
debugPrint(
|
||
'🔍 [选中地块] plot.jsonData 原始内容(前500字符): ${plot.jsonData!.length > 500 ? plot.jsonData!.substring(0, 500) : plot.jsonData!}',
|
||
);
|
||
try {
|
||
final decoded = jsonDecode(plot.jsonData!);
|
||
if (decoded is Map<String, dynamic>) {
|
||
// 🔥 关键检查:jsonData 必须包含 path 或 outer 才算有效数据
|
||
// selectBySiteId 接口返回的 <jsonData/> 是空标签,解析后是 {},没有路径数据
|
||
final hasPath =
|
||
decoded.containsKey('path') && decoded['path'] != null;
|
||
final hasOuter =
|
||
decoded.containsKey('outer') && decoded['outer'] != null;
|
||
if (hasPath || hasOuter) {
|
||
parsedJson = decoded;
|
||
debugPrint(
|
||
'✅ [选中地块] 本地 jsonData 有效,path=$hasPath, outer=$hasOuter',
|
||
);
|
||
} else {
|
||
debugPrint(
|
||
'⚠️ [选中地块] 本地 jsonData 无路径数据 (path=$hasPath, outer=$hasOuter),将回退调接口',
|
||
);
|
||
}
|
||
} else {
|
||
debugPrint(
|
||
'❌ [选中地块] plot.jsonData jsonDecode 结果不是 Map: ${decoded.runtimeType}',
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('❌ [选中地块] plot.jsonData jsonDecode 失败: $e');
|
||
}
|
||
}
|
||
|
||
// 回退:本地 jsonData 为空时,才调接口查询
|
||
if (parsedJson == null) {
|
||
debugPrint('⚠️ [选中地块] 本地 jsonData 为空,回退调用接口查询');
|
||
await context.read<DevicesCubit>().loadSelectedPath(plot.plotName);
|
||
final cubitState = context.read<DevicesCubit>().state;
|
||
final _loadedPlot = cubitState.pathData;
|
||
debugPrint(
|
||
'🔍 [选中地块] pathData 类型: ${_loadedPlot.runtimeType}, 是否为空: ${_loadedPlot?.isEmpty ?? true}',
|
||
);
|
||
|
||
if (_loadedPlot is List && _loadedPlot!.isNotEmpty) {
|
||
final pathRecords = _loadedPlot
|
||
.where((item) => item is Map<String, dynamic>)
|
||
.cast<Map<String, dynamic>>()
|
||
.toList();
|
||
|
||
if (pathRecords.isNotEmpty) {
|
||
final record = pathRecords.first;
|
||
// 🔥 核心修复:直接从接口记录的顶层字段取 path/outer,而不是从 jsonData 中取
|
||
final rawPath = record['path'];
|
||
final rawOuter = record['outer'];
|
||
final planModel = record['planModel'];
|
||
|
||
debugPrint(
|
||
'🔍 [选中地块] 接口顶层 path 类型: ${rawPath.runtimeType}, outer 类型: ${rawOuter.runtimeType}',
|
||
);
|
||
|
||
// 构造 parsedJson,保持 path/outer 为顶层字段
|
||
parsedJson = <String, dynamic>{
|
||
'path': rawPath,
|
||
'outer': rawOuter,
|
||
if (planModel != null) 'planModel': planModel,
|
||
};
|
||
|
||
// 如果顶层 path/outer 都为空,尝试从 jsonData 兜底
|
||
if (rawPath == null &&
|
||
rawOuter == null &&
|
||
record['jsonData'] != null) {
|
||
debugPrint('⚠️ [选中地块] 顶层 path/outer 为空,尝试从 jsonData 兜底');
|
||
final nestedJsonRaw = record['jsonData'];
|
||
if (nestedJsonRaw is String) {
|
||
try {
|
||
final decoded = jsonDecode(nestedJsonRaw);
|
||
if (decoded is Map<String, dynamic>) {
|
||
parsedJson = decoded;
|
||
}
|
||
} catch (_) {}
|
||
} else if (nestedJsonRaw is Map<String, dynamic>) {
|
||
parsedJson = nestedJsonRaw;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (parsedJson == null) {
|
||
debugPrint('❌ [选中地块] 无法获取路径数据,面板显示但 startWorkList 为空');
|
||
setState(() {
|
||
_isWorkPanelOpen = true;
|
||
_isPanelOpen = false;
|
||
_directionBoxOpen = false;
|
||
});
|
||
return;
|
||
}
|
||
|
||
debugPrint(
|
||
'🔍 [选中地块] parsedJson keys: ${parsedJson.keys}, planModel: ${parsedJson['planModel']}',
|
||
);
|
||
|
||
// 🔥 安全解析 planModel:支持 int 和 String 类型
|
||
final int planModelValue =
|
||
int.tryParse(parsedJson['planModel']?.toString() ?? '0') ?? 0;
|
||
debugPrint('🔍 [选中地块] planModelValue: $planModelValue');
|
||
|
||
workMode = planModelValue == WorkMode.bow.value ? "覆盖模式" : "自定义模式";
|
||
_currentWorkMode = planModelValue == WorkMode.bow.value
|
||
? WorkMode.bow
|
||
: WorkMode.custom;
|
||
debugPrint('🔍 [选中地块] workMode: $workMode');
|
||
|
||
// 【优化4】统一 path/outer 解析逻辑,不重复代码
|
||
List<dynamic> pathList = [];
|
||
final rawPath = parsedJson['path'];
|
||
debugPrint('🔍 [选中地块] rawPath 类型: ${rawPath.runtimeType}');
|
||
if (rawPath is String) {
|
||
try {
|
||
pathList = jsonDecode(rawPath) as List;
|
||
} catch (_) {}
|
||
} else if (rawPath is List) {
|
||
pathList = rawPath;
|
||
}
|
||
debugPrint('🔍 [选中地块] pathList 长度: ${pathList.length}');
|
||
if (pathList.isNotEmpty) {
|
||
debugPrint('🔍 [选中地块] pathList[0]: ${pathList.first}');
|
||
}
|
||
|
||
List<dynamic> outerList = [];
|
||
final rawOuter = parsedJson['outer'];
|
||
debugPrint('🔍 [选中地块] rawOuter 类型: ${rawOuter.runtimeType}');
|
||
if (rawOuter is String) {
|
||
try {
|
||
outerList = jsonDecode(rawOuter) as List;
|
||
} catch (_) {}
|
||
} else if (rawOuter is List) {
|
||
outerList = rawOuter;
|
||
}
|
||
debugPrint('🔍 [选中地块] outerList 长度: ${outerList.length}');
|
||
if (outerList.isNotEmpty) {
|
||
debugPrint('🔍 [选中地块] outerList[0]: ${outerList.first}');
|
||
}
|
||
|
||
// 覆盖模式:path是路线轨迹,outer是边界框
|
||
// 自定义模式:只有path
|
||
if (workMode == "覆盖模式") {
|
||
startWorkList = pathList.isNotEmpty ? pathList : outerList;
|
||
} else {
|
||
startWorkList = pathList.isNotEmpty ? pathList : outerList;
|
||
}
|
||
debugPrint('✅ [选中地块] startWorkList 长度: ${startWorkList.length}');
|
||
|
||
// 【优化5】一次性生成坐标,不反复操作数组
|
||
List<LatLng> newPathPoints = [];
|
||
List<LatLng> newOuterPoints = [];
|
||
int pathSkipCount = 0;
|
||
for (var item in pathList) {
|
||
double? lat = safeToDouble(item['lat']);
|
||
double? lon = safeToDouble(item['lng'] ?? item['lon']);
|
||
if (lat != null && lon != null) {
|
||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||
newPathPoints.add(LatLng(lat, lon));
|
||
} else {
|
||
pathSkipCount++;
|
||
if (pathSkipCount <= 3) {
|
||
debugPrint(
|
||
'⚠️ [坐标解析] path 跳过无效坐标: raw=${item}, lat=$lat, lon=$lon',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
int outerSkipCount = 0;
|
||
for (var item in outerList) {
|
||
double? lng = safeToDouble(item['lng'] ?? item['lon']);
|
||
double? lat = safeToDouble(item['lat']);
|
||
if (lng != null && lat != null) {
|
||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||
newOuterPoints.add(LatLng(lat, lng));
|
||
} else {
|
||
outerSkipCount++;
|
||
if (outerSkipCount <= 3) {
|
||
debugPrint(
|
||
'⚠️ [坐标解析] outer 跳过无效坐标: raw=${item}, lat=$lat, lng=$lng',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
debugPrint(
|
||
'✅ [坐标解析] path: 总${pathList.length}, 有效${newPathPoints.length}, 跳过$pathSkipCount',
|
||
);
|
||
debugPrint(
|
||
'✅ [坐标解析] outer: 总${outerList.length}, 有效${newOuterPoints.length}, 跳过$outerSkipCount',
|
||
);
|
||
|
||
// 🔥 容错:后端可能返回 outer 的 lng 全为 0.0(无效经度)
|
||
// 保存时 outer 和 path 都是相同的打点坐标,所以 outer 无效时用 path 代替
|
||
final outerAllLngZero =
|
||
newOuterPoints.isNotEmpty &&
|
||
newOuterPoints.every((p) => p.longitude == 0.0);
|
||
if (outerAllLngZero && newPathPoints.isNotEmpty) {
|
||
debugPrint('⚠️ [坐标解析] outer 所有经度为0(后端数据异常),使用 path 坐标代替 outer');
|
||
newOuterPoints = List.from(newPathPoints);
|
||
}
|
||
|
||
if (newPathPoints.isNotEmpty) {
|
||
debugPrint(
|
||
'✅ [坐标解析] path 首=${newPathPoints.first}, 尾=${newPathPoints.last}',
|
||
);
|
||
}
|
||
if (newOuterPoints.isNotEmpty) {
|
||
debugPrint(
|
||
'✅ [坐标解析] outer 首=${newOuterPoints.first}, 尾=${newOuterPoints.last}',
|
||
);
|
||
}
|
||
|
||
// 【优化6】整个方法只调用一次 setState,性能暴涨
|
||
// 🔥 覆盖模式(0):显示 outer灰色透明框 + path路线轨迹
|
||
// 🔥 自定义模式(2):只显示 path
|
||
setState(() {
|
||
gcjPathPoints = newPathPoints; // 两种模式都显示 path
|
||
gcjOuterPoints = _currentWorkMode == WorkMode.bow
|
||
? newOuterPoints // 覆盖模式:显示 outer 边框
|
||
: []; // 自定义模式:不显示 outer
|
||
_isWorkPanelOpen = true;
|
||
_isPanelOpen = false;
|
||
_directionBoxOpen = false;
|
||
});
|
||
debugPrint(
|
||
'✅ [选中地块] 最终渲染: gcjPathPoints=${gcjPathPoints.length}, gcjOuterPoints=${gcjOuterPoints.length}, workMode=$_currentWorkMode',
|
||
);
|
||
|
||
// 最后移动地图
|
||
debugPrint('🗺️ [选中地块] 准备移动地图到作业区域中心...');
|
||
List<LatLng> allPoints = [];
|
||
allPoints.addAll(gcjPathPoints);
|
||
allPoints.addAll(gcjOuterPoints);
|
||
debugPrint(
|
||
'🗺️ [选中地块] 准备移动地图: allPoints=${allPoints.length}, gcjPathPoints=${gcjPathPoints.length}, gcjOuterPoints=${gcjOuterPoints.length}',
|
||
);
|
||
if (allPoints.isNotEmpty) {
|
||
moveMapToPointsCenter(allPoints);
|
||
} else {
|
||
debugPrint('❌ [选中地块] allPoints 为空,无法移动地图!');
|
||
}
|
||
} catch (e, stackTrace) {
|
||
debugPrint('❌ 解析失败:$e');
|
||
debugPrint('❌ 解析失败堆栈:$stackTrace');
|
||
setState(() {
|
||
gcjPathPoints = [];
|
||
gcjOuterPoints = [];
|
||
_isWorkPanelOpen = true;
|
||
_isPanelOpen = false;
|
||
_directionBoxOpen = false;
|
||
});
|
||
} finally {
|
||
_saveDataToLocal();
|
||
}
|
||
},
|
||
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(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
LatLng _getDefaultValidCenter() {
|
||
// 优先进场定位到“当前选中场站”的高清图层中心;无有效场站时兜底到苏州地块中心
|
||
final center = stationLayerCenter(
|
||
sl<SiteCubit>().state.selectedSite?.siteMapName,
|
||
);
|
||
return center ?? const LatLng(31.083296, 120.840355);
|
||
}
|
||
|
||
Widget _buildMap(
|
||
double lat,
|
||
double lng,
|
||
bool obfFlag,
|
||
int headingStatus,
|
||
String controlMode,
|
||
) {
|
||
_onDeviceData(lng, lat, obfFlag, headingStatus, controlMode);
|
||
|
||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||
final currentPoint = LatLng(lat, lng);
|
||
|
||
//final initCenter = bd09ToGcj02(
|
||
// '120.81992468426961', // 经度
|
||
// '32.04532826114155',
|
||
//);
|
||
|
||
// 🔥 修复1:初始化坐标改为直接使用有效WGS84坐标
|
||
final initCenter = _getDefaultValidCenter();
|
||
_currentWgsLatLng = LatLng(lat, lng);
|
||
|
||
_traceManager.upsert(_currentWgsLatLng as PlotPoint, TPAction.UPDATE);
|
||
tracePoint = _traceManager.getTracePoint();
|
||
// ⚠️ 地图已切换为 WGS84,直接使用原始轨迹点
|
||
gctracePoint = List.from(tracePoint!);
|
||
// _logger.log("[当前轨迹模式][转换后gctracePoint]: ${gctracePoint!.length}");
|
||
// _logger.log("[当前轨迹模式]:${_traceManager.getMode()}"); // 这里必须加 ()
|
||
|
||
//_currentLatLng = currentPoint; // ✅ 状态变量在setState内更新
|
||
if (_isValidLatLng(currentPoint.latitude, currentPoint.longitude)) {
|
||
_currentLatLng = currentPoint;
|
||
} else {
|
||
_currentLatLng = null;
|
||
}
|
||
|
||
return FlutterMap(
|
||
mapController: _mapController,
|
||
options: MapOptions(
|
||
initialCenter: __isValidLatLng(_currentLatLng)
|
||
? _currentLatLng!
|
||
: initCenter,
|
||
initialZoom: 18,
|
||
maxZoom: 22,
|
||
minZoom: 3,
|
||
enableScrollWheel: true,
|
||
// 🔥 视觉放大:通过缩放系数实现"伪20级"显示(无空白)
|
||
onTap: (_, __) {}, // 空实现,禁用地图点击响应
|
||
),
|
||
children: [
|
||
/// ESRI World Imagery(WGS84 坐标系,全球高清卫星图)
|
||
/// 替代高德地图(GCJ02),统一坐标系,无需坐标转换
|
||
TileLayer(
|
||
urlTemplate:
|
||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||
userAgentPackageName: 'com.example.app',
|
||
minZoom: 0,
|
||
maxZoom: 19,
|
||
),
|
||
|
||
/// GeoServer 全部场站无人机高清影像叠加层(WGS84 基准,无偏移)
|
||
/// 图层范围优先取自 GeoServer GetCapabilities(动态),失败回退内置兜底
|
||
/// 滑到哪个场站区域(zoom>=16)就自动显示哪个高清图;每层各自 tileBounds,框外不请求
|
||
/// WMS 瓦片带磁盘缓存+超时(CachedWmsImage),二次进入秒开
|
||
..._stationLayers,
|
||
//if (isDjMapShow) DJITileLayer(stationName: _currentStation.name, mapController: _mapController),
|
||
|
||
// 绘制path折线(Line模式)
|
||
if (gcjPathPoints.isNotEmpty)
|
||
PolylineLayer(
|
||
polylines: [
|
||
Polyline(
|
||
points: gcjPathPoints, // 转换后的GCJ02坐标
|
||
color: const Color.fromARGB(255, 255, 204, 0), // 金黄色(更醒目)
|
||
strokeWidth: 1.0, // 折线宽度
|
||
isDotted: false, // 非虚线(Line模式)
|
||
borderColor: Colors.white, // 可选:添加白色描边,提升辨识度
|
||
borderStrokeWidth: 1,
|
||
),
|
||
],
|
||
),
|
||
|
||
// 绘制outer边框(Polygon模式)—— 覆盖模式:灰色透明框 + 内部路线轨迹
|
||
if (gcjOuterPoints.isNotEmpty && _currentWorkMode == WorkMode.bow)
|
||
PolygonLayer(
|
||
polygons: [
|
||
Polygon(
|
||
points: gcjOuterPoints, // 转换后的GCJ02坐标
|
||
color: Colors.grey.withOpacity(0.15), // 灰色半透明填充
|
||
borderColor: Colors.grey.withOpacity(0.6), // 灰色半透明边框
|
||
borderStrokeWidth: 1.5, // 边框宽度
|
||
isFilled: true, // 开启填充
|
||
),
|
||
],
|
||
),
|
||
if (gcjOuterPoints.isNotEmpty && _currentWorkMode == WorkMode.custom)
|
||
PolylineLayer(
|
||
polylines: [
|
||
Polyline(
|
||
points: gcjOuterPoints, // 转换后的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 &&
|
||
_currentRobotMode == RobotMode.point)
|
||
Stack(
|
||
// 核心:替换Column为Stack
|
||
children: [
|
||
// 1. 蓝色连线
|
||
PolylineLayer(
|
||
polylines: [
|
||
Polyline(
|
||
points: [_mapCenter, _markedPoints.last],
|
||
color: Colors.blue.withOpacity(0.5),
|
||
strokeWidth: 1.5,
|
||
),
|
||
],
|
||
),
|
||
// 2. 距离文本Marker
|
||
MarkerLayer(
|
||
markers: [
|
||
Marker(
|
||
point: _getMiddleLatLng(_mapCenter, _markedPoints.last),
|
||
width: 100,
|
||
height: 30,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 8,
|
||
vertical: 4,
|
||
),
|
||
child: Text(
|
||
_calculateLatLngDistance(
|
||
_mapCenter,
|
||
_markedPoints.last,
|
||
),
|
||
style: const TextStyle(
|
||
color: Colors.red,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
|
||
if (_currentWorkMode == WorkMode.bow &&
|
||
_markedPoints.isNotEmpty &&
|
||
!_isWorkAreaCompleted &&
|
||
_currentRobotMode == RobotMode.point)
|
||
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(),
|
||
),
|
||
//开始作业之后
|
||
|
||
// 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, // 虚线区分作业区域
|
||
),
|
||
if (_currentRobotMode == RobotMode.point)
|
||
// 最后一个点到地图中心的连线
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
// 只在 ≥2 个点时才显示图层,否则直接消失!
|
||
if (gctracePoint != null && gctracePoint!.length >= 2)
|
||
PolylineLayer(
|
||
key: const ValueKey('trace_polyline'),
|
||
polylines: [
|
||
Polyline(
|
||
points: gctracePoint!,
|
||
color: const Color(0xFF00B42A), // 绿色实时轨迹线
|
||
strokeWidth: 4,
|
||
),
|
||
],
|
||
),
|
||
|
||
/// 当前定位 Marker
|
||
if (_currentLatLng != null && __isValidLatLng(_currentLatLng))
|
||
MarkerLayer(
|
||
markers: [
|
||
Marker(
|
||
point: _currentLatLng!,
|
||
width: 40,
|
||
height: 40,
|
||
child: CustomPaint(
|
||
size: const Size(40, 40),
|
||
painter: HeadingMarkerPainter(headingAngle: _headingAngle),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
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.fromARGB(255, 10, 218, 55), // 绿色主题色
|
||
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(),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 🔥 显示任务选择弹窗
|
||
Future<DeviceTaskEntity?> _showTaskSelectionDialog(
|
||
List<DeviceTaskEntity> tasks,
|
||
) async {
|
||
return showDialog<DeviceTaskEntity>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (dialogContext) => AlertDialog(
|
||
title: const Text('选择任务'),
|
||
content: SizedBox(
|
||
width: double.maxFinite,
|
||
child: ListView.builder(
|
||
shrinkWrap: true,
|
||
itemCount: tasks.length,
|
||
itemBuilder: (context, index) {
|
||
final task = tasks[index];
|
||
// 状态标签颜色
|
||
Color statusColor;
|
||
String statusText;
|
||
switch (task.taskStatus) {
|
||
case 'NEW':
|
||
statusColor = Colors.blue;
|
||
statusText = '新建';
|
||
break;
|
||
case 'EXECUTING':
|
||
statusColor = Colors.green;
|
||
statusText = '执行中';
|
||
break;
|
||
case 'PAUSE':
|
||
statusColor = Colors.orange;
|
||
statusText = '暂停中';
|
||
break;
|
||
default:
|
||
statusColor = Colors.grey;
|
||
statusText = task.taskStatusTranslate;
|
||
}
|
||
|
||
return Card(
|
||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||
child: ListTile(
|
||
title: Text('任务 #${task.id}'),
|
||
subtitle: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'设备号: ${task.deviceId}',
|
||
softWrap: true,
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
Row(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 6,
|
||
vertical: 2,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: statusColor.withOpacity(0.1),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
statusText,
|
||
style: TextStyle(
|
||
color: statusColor,
|
||
fontSize: 12,
|
||
),
|
||
),
|
||
),
|
||
if (task.createTime != null) ...[
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
task.createTime!,
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
color: Colors.grey,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
onTap: () {
|
||
Navigator.pop(dialogContext, task);
|
||
},
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(dialogContext, null),
|
||
child: const Text('取消'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 开始作业
|
||
void _startWork() async {
|
||
// 🔥 V2 适配:使用接口方式执行作业,不再使用 TCP
|
||
|
||
// 1. 校验选中的路线
|
||
if (_selectedPlot == null) {
|
||
_showPageToast(message: "请先选择一个路线任务", type: ToastType.info);
|
||
return;
|
||
}
|
||
|
||
// 2. 获取设备ID(从 RemoteControlCubit 的 targetDevice)
|
||
final targetDevice = context.read<RemoteControlCubit>().state.targetDevice;
|
||
final deviceId = targetDevice?.deviceName;
|
||
if (deviceId == null || deviceId.isEmpty) {
|
||
_showPageToast(message: "请先选择一个设备", type: ToastType.info);
|
||
return;
|
||
}
|
||
|
||
// 3. 获取路线ID(从选中的路线任务)
|
||
final routeId = int.tryParse(_selectedPlot!.id);
|
||
if (routeId == null) {
|
||
_showPageToast(message: "路线ID无效", type: ToastType.error);
|
||
return;
|
||
}
|
||
|
||
// 4. 获取场站ID(从 SiteCubit 的 selectedSite)
|
||
final siteId = sl<SiteCubit>().state.selectedSite?.id;
|
||
if (siteId == null) {
|
||
_showPageToast(message: "请先选择一个场站", type: ToastType.info);
|
||
return;
|
||
}
|
||
|
||
// 5. 获取组织ID(从当前选中场站的 orgId)
|
||
final orgId = sl<SiteCubit>().state.selectedSite?.orgId;
|
||
if (orgId == null) {
|
||
_showPageToast(message: "场站信息异常,请重新选择场站", type: ToastType.error);
|
||
return;
|
||
}
|
||
|
||
// 🔥 6. 先过滤出活跃任务,让用户选择
|
||
debugPrint('══════════ [开始作业] 开始 ══════════');
|
||
debugPrint('🔍 [开始作业] 步骤0: 查询活跃任务, deviceId=$deviceId');
|
||
final taskCubit = sl<DeviceTaskCubit>();
|
||
await taskCubit.fetchAndFilterTask(deviceId);
|
||
|
||
// 等待一下让状态更新
|
||
await Future.delayed(const Duration(milliseconds: 100));
|
||
|
||
final activeTasks = taskCubit.state.activeTasks;
|
||
debugPrint('✅ [开始作业] 找到 ${activeTasks.length} 个活跃任务');
|
||
|
||
// 🔥 6. 任务选择(仅当有活跃任务时才需要选择,否则直接创建新任务)
|
||
DeviceTaskEntity? selectedTask;
|
||
if (activeTasks.length > 1) {
|
||
debugPrint('⚠️ [开始作业] 有多个活跃任务,显示选择弹窗');
|
||
selectedTask = await _showTaskSelectionDialog(activeTasks);
|
||
if (selectedTask == null) {
|
||
debugPrint('❌ [开始作业] 用户取消选择任务');
|
||
return;
|
||
}
|
||
taskCubit.selectTask(selectedTask);
|
||
} else if (activeTasks.length == 1) {
|
||
selectedTask = activeTasks.first;
|
||
taskCubit.selectTask(selectedTask); // 🔥 单任务也要存入 cubit
|
||
debugPrint('✅ [开始作业] 自动选择唯一任务 #${selectedTask.id}');
|
||
} else {
|
||
// 🔥 无活跃任务:先清除旧的本地 taskId,再创建新任务
|
||
debugPrint('ℹ️ [开始作业] 无活跃任务,清除旧 taskId 后创建新任务');
|
||
await _clearTaskIdFromLocal(deviceId);
|
||
taskCubit.clearCurrentTask();
|
||
}
|
||
|
||
debugPrint(
|
||
'📋 [开始作业] 选中的任务ID: ${selectedTask?.id ?? "(新建)"}, 状态: ${selectedTask?.taskStatus ?? "N/A"}',
|
||
);
|
||
|
||
// 7. 打印请求参数日志
|
||
debugPrint('🚀 [开始作业] 请求参数:');
|
||
debugPrint(' ├─ deviceId: $deviceId');
|
||
debugPrint(' ├─ routeId: $routeId');
|
||
debugPrint(' ├─ siteId: $siteId');
|
||
debugPrint(' ├─ orgId: $orgId');
|
||
debugPrint(' └─ taskId: ${selectedTask?.id ?? "(无,将新建)"}');
|
||
|
||
// 8. 重置轨迹 + 切换到轨迹模式(TRACK 模式积累轨迹点,绘制实时轨迹线)
|
||
debugPrint('🔄 [开始作业] 步骤1: 重置轨迹,切换到TRACK模式');
|
||
_traceManager.reset();
|
||
_traceManager.setMode(TPMode.TRACK);
|
||
tracePoint?.clear();
|
||
gctracePoint?.clear();
|
||
|
||
// 10. 如果池子里已有活跃任务,直接使用;否则创建新任务
|
||
debugPrint('🔀 [开始作业] 步骤2: 确认任务来源');
|
||
if (selectedTask != null) {
|
||
// 🔥 验证 taskId 是否有效
|
||
debugPrint('📋 [开始作业] 步骤2: 使用已有任务 #${selectedTask.id}');
|
||
if (selectedTask.id == null) {
|
||
debugPrint('⚠️ [开始作业] 活跃任务无有效 taskId,无法控制');
|
||
_showPageToast(message: "已有任务在其他平台执行,暂不可控制", type: ToastType.warn);
|
||
return;
|
||
}
|
||
// 🔥 池子里已有任务,直接使用,不需要再创建
|
||
debugPrint('✅ [开始作业] 步骤2完成: 使用已有任务 #${selectedTask.id},跳过创建');
|
||
// taskId 已由 selectTask 存入 cubit
|
||
} else {
|
||
// 🔥 池子里没有,创建新任务
|
||
debugPrint('🆕 [开始作业] 步骤2a: 池子为空,创建新任务...');
|
||
try {
|
||
debugPrint(
|
||
'📤 [开始作业] 调用 createDeviceTask API, deviceId=$deviceId, routeId=$routeId',
|
||
);
|
||
final result = await sl<CreateDeviceTaskUseCase>().execute(
|
||
deviceId: deviceId,
|
||
routeId: routeId,
|
||
siteId: siteId,
|
||
orgId: orgId,
|
||
);
|
||
|
||
var needRequery = false;
|
||
result.fold(
|
||
(failure) {
|
||
final failMsg = failure.toString();
|
||
debugPrint('⚠️ [开始作业] createDeviceTask API 失败: $failMsg');
|
||
// 🔥 无论什么失败(网络错误/任务已存在等),都尝试重查池子
|
||
// 网络错误时服务端可能已创建成功,只是响应丢失
|
||
needRequery = true;
|
||
},
|
||
(taskId) {
|
||
debugPrint('✅ [开始作业] createDeviceTask 成功, taskId=$taskId');
|
||
taskCubit.updateCurrentTaskId(taskId);
|
||
},
|
||
);
|
||
|
||
if (needRequery) {
|
||
debugPrint('🔁 [开始作业] 步骤2b: API失败,重新查询池子(服务端可能已创建)...');
|
||
await taskCubit.fetchAndFilterTask(deviceId);
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
final existingTasks = taskCubit.state.activeTasks;
|
||
if (existingTasks.isNotEmpty) {
|
||
taskCubit.selectTask(existingTasks.first);
|
||
debugPrint('✅ [开始作业] 重查成功,使用已有任务 #${existingTasks.first.id}');
|
||
// 🔥 继续执行后续 UI更新 + MQTT + 保存逻辑
|
||
} else {
|
||
debugPrint('❌ [开始作业] 步骤2b失败: 重查后仍无活跃任务,终止');
|
||
_showPageToast(message: '作业启动失败,未找到活跃任务', type: ToastType.error);
|
||
taskCubit.clearCurrentTask();
|
||
return;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
debugPrint('❌ [开始作业] 步骤2异常: $e');
|
||
_showPageToast(message: "作业启动异常: $e", type: ToastType.error);
|
||
taskCubit.clearCurrentTask();
|
||
return;
|
||
}
|
||
} // end if/else 任务确认
|
||
|
||
// 🔥 安全检查:taskId 必须有效才能更新 UI 为作业中
|
||
if (taskCubit.state.currentTaskId == null) {
|
||
debugPrint('❌ [开始作业] 步骤3前检查: taskId 为空,任务未就绪,终止');
|
||
_showPageToast(message: '作业启动失败,未获取到任务ID', type: ToastType.error);
|
||
return;
|
||
}
|
||
|
||
// 🔥 暂时注释:步骤2.5 - 调用 getTaskPath 接口获取完整路径数据(后端接口未联调,待后续启用)
|
||
/*
|
||
debugPrint('📍 [开始作业] 步骤2.5: 调用 getTaskPath 接口获取路径数据');
|
||
final currentTaskId = taskCubit.state.currentTaskId;
|
||
bool pathFetchSuccess = false;
|
||
List<LatLng>? fetchedPathPoints;
|
||
|
||
try {
|
||
final dio = sl<Dio>();
|
||
final response = await dio.get(
|
||
'${HttpApiConsts.baseUrl}/iot/taskPath/getTaskPath',
|
||
queryParameters: {
|
||
'taskId': currentTaskId,
|
||
},
|
||
);
|
||
|
||
if (response.statusCode == 200) {
|
||
final data = response.data as Map<String, dynamic>;
|
||
final code = data['code'];
|
||
final codeValue = code is int ? code : int.tryParse(code?.toString() ?? '');
|
||
|
||
if (codeValue == 200) {
|
||
final pathData = data['data'];
|
||
debugPrint('✅ [开始作业] getTaskPath 接口调用成功, data类型: ${pathData.runtimeType}');
|
||
|
||
if (pathData != null && pathData is Map<String, dynamic>) {
|
||
final pathList = pathData['path'];
|
||
if (pathList != null && pathList is List && pathList.isNotEmpty) {
|
||
debugPrint('✅ [开始作业] 获取到路径点数: ${pathList.length}');
|
||
|
||
// 将路径点渲染到地图上
|
||
fetchedPathPoints = pathList.map((point) {
|
||
final lat = (point['lat'] as num?)?.toDouble() ?? 0.0;
|
||
final lng = (point['lng'] ?? point['lon'] as num?)?.toDouble() ?? 0.0;
|
||
return LatLng(lat, lng);
|
||
}).toList();
|
||
|
||
// 渲染路径到地图
|
||
setState(() {
|
||
gcjPathPoints = fetchedPathPoints!;
|
||
typedPathList = fetchedPathPoints!.map((latLng) {
|
||
return work_area_model.DeviceAddPathPointModel(
|
||
latitude: latLng.latitude,
|
||
longitude: latLng.longitude,
|
||
);
|
||
}).toList();
|
||
});
|
||
|
||
pathFetchSuccess = true;
|
||
debugPrint('✅ [开始作业] 路径已渲染到地图');
|
||
} else {
|
||
debugPrint('⚠️ [开始作业] 路径数据为空');
|
||
}
|
||
} else {
|
||
debugPrint('⚠️ [开始作业] 路径数据结构格式错误');
|
||
}
|
||
} else {
|
||
debugPrint('❌ [开始作业] getTaskPath 接口返回业务失败: $code');
|
||
}
|
||
} else {
|
||
debugPrint('❌ [开始作业] getTaskPath 接口HTTP失败: ${response.statusCode}');
|
||
}
|
||
} catch (e) {
|
||
debugPrint('❌ [开始作业] getTaskPath 接口调用异常: $e');
|
||
}
|
||
|
||
// 🔥 判断路径获取结果
|
||
if (!pathFetchSuccess) {
|
||
debugPrint('❌ [开始作业] 路径数据获取失败或为空,关闭loading,弹出提示,终止作业启动');
|
||
setState(() {
|
||
isStopWork = false;
|
||
isStartWork = false;
|
||
_workStatus = WorkStatus.idle;
|
||
});
|
||
_showPageToast(
|
||
message: '获取路径数据失败,无法启动作业',
|
||
type: ToastType.error,
|
||
);
|
||
return;
|
||
}
|
||
*/
|
||
|
||
debugPrint(
|
||
'🎯 [开始作业] 步骤4: 任务就绪, currentTaskId=${taskCubit.state.currentTaskId}',
|
||
);
|
||
setState(() {
|
||
isStopWork = false;
|
||
isStartWork = true;
|
||
_workStatus = WorkStatus.working;
|
||
});
|
||
context.read<DevicesCubit>().updateAppState(AppState.routePlanning);
|
||
|
||
// 🔥 步骤5: 刷新任务池(保护 taskId 不被覆盖)
|
||
debugPrint('🔄 [开始作业] 步骤5: 刷新任务池');
|
||
final _confirmedTaskId = taskCubit.state.currentTaskId;
|
||
await taskCubit.fetchAndFilterTask(deviceId);
|
||
if (_confirmedTaskId != null && taskCubit.state.currentTaskId == null) {
|
||
taskCubit.updateCurrentTaskId(_confirmedTaskId);
|
||
debugPrint('🛡️ [开始作业] taskId 被刷新覆盖,已还原: $_confirmedTaskId');
|
||
}
|
||
|
||
// 🔥 步骤6: 启动MQTT到达点监听
|
||
debugPrint(
|
||
'📡 [开始作业] 步骤6: 启动MQTT监听, deviceId: $deviceId, taskId: $_confirmedTaskId',
|
||
);
|
||
try {
|
||
await context.read<DevicesCubit>().startListeningMqttArrive(
|
||
deviceId: deviceId,
|
||
taskId: _confirmedTaskId!,
|
||
);
|
||
debugPrint('✅ [开始作业] MQTT到达点监听已启动');
|
||
} catch (e) {
|
||
debugPrint('⚠️ [开始作业] MQTT监听启动失败: $e (非致命,继续)');
|
||
}
|
||
|
||
// 🔥 步骤6.5: 监听路径规划 MQTT 消息(mowerRealtimeClient:59007),绘制覆盖清扫路线
|
||
_routeStatusSub?.cancel();
|
||
_routeStatusSub = sl<MowerRealtimeRepository>().routeStatusStream.listen((
|
||
routeMsg,
|
||
) {
|
||
debugPrint(
|
||
'🛤️ [MapPage] 收到路径规划消息, 设备: ${routeMsg.deviceId}, 任务: ${routeMsg.taskId}, 点数: ${routeMsg.data.length}',
|
||
);
|
||
setState(() {
|
||
gcjPathPoints = routeMsg.data.map((point) {
|
||
return LatLng(point.lat, point.lng);
|
||
}).toList();
|
||
});
|
||
debugPrint('✅ [MapPage] 覆盖清扫路线已渲染到地图');
|
||
});
|
||
|
||
// 🔥 步骤7: 持久化状态
|
||
debugPrint('💾 [开始作业] 步骤7: 持久化状态, taskId=${taskCubit.state.currentTaskId}');
|
||
_showPageToast(message: "作业已开始", type: ToastType.success);
|
||
_saveDataToLocal();
|
||
|
||
// ============ 以下是原有的 TCP 方式代码,已注释 ============
|
||
/*
|
||
if (startWorkList.isEmpty) {
|
||
_showPageToast(message: "作业列表为空,请重新选择路径", type: ToastType.info);
|
||
return;
|
||
}
|
||
_traceManager.setMode(TPMode.LOCATION);
|
||
tracePoint = _traceManager.getTracePoint();
|
||
gctracePoint = batchWgs84ToGcj02(tracePoint!);
|
||
// _logger.log("[当前轨迹模式][转换后gctracePoint]开始作业: $tracePoint");
|
||
// _logger.log("[当前轨迹模式][转换后gctracePoint]开始作业: $gctracePoint");
|
||
// _logger.log("[当前轨迹模式][转换后gctracePoint]开始作业= ${gctracePoint!.length}");
|
||
|
||
setState(() {
|
||
isStopWork = false;
|
||
isStartWork = true;
|
||
_workStatus = WorkStatus.working;
|
||
_traceManager.reset();
|
||
tracePoint?.clear();
|
||
gctracePoint?.clear();
|
||
});
|
||
|
||
context.read<DevicesCubit>().updateAppState(AppState.routePlanning);
|
||
await Future.delayed(const Duration(milliseconds: 100));
|
||
|
||
debugPrint('⚙️ 开始类型转换...');
|
||
final List<work_area_model.DeviceAddPathPointModel> typedList =
|
||
startWorkList.whereType<Map<String, dynamic>>().map((item) {
|
||
return work_area_model.DeviceAddPathPointModel(
|
||
latitude: (item['lat'] as num).toDouble(),
|
||
longitude: (item['lng'] ?? item["lon"] as num).toDouble(),
|
||
);
|
||
}).toList();
|
||
|
||
final Queue<work_area_model.DeviceAddPathPointModel> pathQueue = Queue.from(typedList);
|
||
await context.read<DevicesCubit>().startRoutePlanning(pathQueue);
|
||
|
||
_showPageToast(message: "作业已开始", type: ToastType.success);
|
||
_saveDataToLocal();
|
||
*/
|
||
}
|
||
|
||
/// 暂停作业
|
||
void _pauseWork() async {
|
||
// 🔥 获取设备ID和taskId
|
||
final targetDevice = context.read<RemoteControlCubit>().state.targetDevice;
|
||
final deviceId = targetDevice?.deviceName;
|
||
if (deviceId == null || deviceId.isEmpty) {
|
||
_showPageToast(message: "请先选择一个设备", type: ToastType.info);
|
||
return;
|
||
}
|
||
|
||
final taskCubit = sl<DeviceTaskCubit>();
|
||
var taskId = taskCubit.state.currentTaskId;
|
||
|
||
// 🔥 taskId 为 null 时尝试从 SharedPreferences 恢复
|
||
if (taskId == null) {
|
||
debugPrint('📥 [暂停作业] taskId 为空,尝试从本地恢复...');
|
||
taskId = await _restoreTaskIdFromLocal(deviceId);
|
||
if (taskId != null) {
|
||
taskCubit.updateCurrentTaskId(taskId);
|
||
debugPrint('📥 [暂停作业] 从本地恢复 taskId: $taskId');
|
||
}
|
||
}
|
||
|
||
if (taskId == null) {
|
||
debugPrint('❌ [暂停作业] taskId 为空,无法操控');
|
||
_showPageToast(message: "任务ID为空,无法操控", type: ToastType.warn);
|
||
return;
|
||
}
|
||
|
||
debugPrint('⏸️ [暂停作业] deviceId: $deviceId, taskId: $taskId');
|
||
|
||
setState(() {
|
||
isStartWork = false; // 🔥 关键:停止作业标志
|
||
_workStatus = WorkStatus.paused;
|
||
});
|
||
_saveDataToLocal(); // 🔥 保存暂停状态
|
||
|
||
// 🔥 调用接口暂停任务(注释掉原有的 TCP 方式)
|
||
try {
|
||
await taskCubit.pauseTask(deviceId);
|
||
_showPageToast(message: "作业已暂停", type: ToastType.success);
|
||
} catch (e) {
|
||
debugPrint('❌ [暂停作业] 异常: $e');
|
||
_showPageToast(message: "暂停失败: $e", type: ToastType.error);
|
||
}
|
||
|
||
// 原有的 TCP 方式(已注释)
|
||
//context.read<DevicesCubit>().updateAppState(AppState.none);
|
||
//await context.read<DevicesCubit>().pauseRoutePlanning();
|
||
}
|
||
|
||
/// 🔥 关闭底部工作面板(等同于点击 X 号)
|
||
void _closeWorkPanel() {
|
||
debugPrint('❌ [工作面板] 关闭面板,重置状态');
|
||
setState(() {
|
||
_isWorkPanelOpen = false;
|
||
_selectedPlot = null;
|
||
gcjPathPoints = [];
|
||
gcjOuterPoints = [];
|
||
_workStatus = WorkStatus.idle;
|
||
_showHeadingWarn = false;
|
||
_showControlModeWarn = false;
|
||
isStartWork = false;
|
||
});
|
||
_saveDataToLocal();
|
||
_stopWork();
|
||
}
|
||
|
||
/// 🔥 停止作业前弹窗选择取消方式:普通取消(false) / 强制取消(true)
|
||
void _confirmStopWork() {
|
||
showDialog<void>(
|
||
context: context,
|
||
barrierDismissible: true,
|
||
builder: (dialogContext) {
|
||
return Dialog(
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Row(
|
||
children: [
|
||
Icon(
|
||
Icons.warning_amber_rounded,
|
||
color: Colors.redAccent,
|
||
size: 24,
|
||
),
|
||
SizedBox(width: 8),
|
||
Text(
|
||
'选择取消方式',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
const Text(
|
||
'请选择取消任务的方式',
|
||
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||
),
|
||
const SizedBox(height: 20),
|
||
Row(
|
||
children: [
|
||
// 普通取消
|
||
Expanded(
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.orange,
|
||
foregroundColor: Colors.white,
|
||
minimumSize: const Size.fromHeight(48),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
),
|
||
onPressed: () {
|
||
Navigator.pop(dialogContext);
|
||
_stopWork(forceCancel: false);
|
||
},
|
||
child: const Text(
|
||
'普通取消',
|
||
style: TextStyle(fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
// 强制取消
|
||
Expanded(
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.redAccent,
|
||
foregroundColor: Colors.white,
|
||
minimumSize: const Size.fromHeight(48),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
),
|
||
onPressed: () {
|
||
Navigator.pop(dialogContext);
|
||
_stopWork(forceCancel: true);
|
||
},
|
||
child: const Text(
|
||
'强制取消',
|
||
style: TextStyle(fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: TextButton(
|
||
onPressed: () => Navigator.pop(dialogContext),
|
||
child: const Text('关闭'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _stopWork({bool forceCancel = true}) async {
|
||
_logger.log('[停止] 按下停止按钮,forceCancel=$forceCancel');
|
||
debugPrint('══════════ [停止作业] 开始 ══════════');
|
||
|
||
// 🔥 获取设备ID和taskId
|
||
final targetDevice = context.read<RemoteControlCubit>().state.targetDevice;
|
||
final deviceId = targetDevice?.deviceName;
|
||
debugPrint(
|
||
'[停止作业] deviceId: $deviceId, targetDevice: ${targetDevice?.deviceName}',
|
||
);
|
||
if (deviceId == null || deviceId.isEmpty) {
|
||
debugPrint('[停止作业] ❌ deviceId 为空,退出');
|
||
_showPageToast(message: "请先选择一个设备", type: ToastType.info);
|
||
return;
|
||
}
|
||
|
||
final taskCubit = sl<DeviceTaskCubit>();
|
||
var taskId = taskCubit.state.currentTaskId;
|
||
debugPrint('[停止作业] taskId: $taskId');
|
||
|
||
// 🔥 taskId 为 null 时尝试从 SharedPreferences 恢复
|
||
if (taskId == null) {
|
||
debugPrint('📥 [停止作业] taskId 为空,尝试从本地恢复...');
|
||
taskId = await _restoreTaskIdFromLocal(deviceId);
|
||
if (taskId != null) {
|
||
taskCubit.updateCurrentTaskId(taskId);
|
||
debugPrint('📥 [停止作业] 从本地恢复 taskId: $taskId');
|
||
}
|
||
}
|
||
|
||
if (taskId == null) {
|
||
debugPrint('[停止作业] ❌ taskId 为空,无法操控');
|
||
_showPageToast(message: "任务ID为空,无法操控", type: ToastType.warn);
|
||
return;
|
||
}
|
||
|
||
debugPrint(
|
||
'[停止作业] 准备调用 cancelTask API, deviceId: $deviceId, taskId: $taskId',
|
||
);
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
isStartWork = false;
|
||
_workStatus = WorkStatus.idle;
|
||
_showHeadingWarn = false;
|
||
_showControlModeWarn = false;
|
||
isStopWork = true;
|
||
isreceiveFirstCompletePoint = false;
|
||
|
||
// 1. 立刻切换模式(阻止新的点再进入 NAVIGATION 逻辑)
|
||
_traceManager.setMode(TPMode.LOCATION);
|
||
});
|
||
debugPrint('[停止作业] setState 完成,workStatus = idle');
|
||
}
|
||
|
||
// 🔥 调用接口取消任务
|
||
try {
|
||
debugPrint(
|
||
'[停止作业] 正在调用 taskCubit.cancelTask, forceCancel=$forceCancel...',
|
||
);
|
||
await taskCubit.cancelTask(deviceId, forceCancel: forceCancel);
|
||
debugPrint('[停止作业] cancelTask 返回成功');
|
||
taskCubit.clearCurrentTask(); // 🔥 停止后清除 taskId,释放任务
|
||
debugPrint('[停止作业] clearCurrentTask 完成');
|
||
await _clearTaskIdFromLocal(deviceId); // 🔥 同步清除本地持久化
|
||
|
||
// 🔥 停止MQTT到达点监听
|
||
debugPrint('[停止作业] 停止MQTT到达点监听...');
|
||
await context.read<DevicesCubit>().stopListeningMqttArrive();
|
||
debugPrint('[停止作业] MQTT到达点监听已停止');
|
||
|
||
_showPageToast(message: "作业已停止", type: ToastType.success);
|
||
debugPrint('[停止作业] HTTP 取消成功,准备发送 TCP 停止指令');
|
||
|
||
// 🔥 还需要通过 TCP 向机器发送停止指令
|
||
try {
|
||
debugPrint('[停止作业] 调用 TCP stopRoutePlanning...');
|
||
await context.read<DevicesCubit>().stopRoutePlanning();
|
||
debugPrint('[停止作业] TCP stopRoutePlanning 完成');
|
||
} catch (tcpError) {
|
||
debugPrint('[停止作业] ⚠️ TCP 停止异常: $tcpError');
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[停止作业] ❌ cancelTask 异常: $e');
|
||
_showPageToast(message: "停止失败: $e", type: ToastType.error);
|
||
}
|
||
|
||
_saveDataToLocal();
|
||
debugPrint('[停止作业] _saveDataToLocal 完成');
|
||
await Future.delayed(const Duration(milliseconds: 300));
|
||
if (mounted) {
|
||
setState(() {
|
||
// 2. 延迟后重置 + 清空(彻底绝杀残留点)
|
||
_traceManager.reset();
|
||
tracePoint?.clear();
|
||
gctracePoint?.clear();
|
||
tracePoint = [];
|
||
gctracePoint = [];
|
||
});
|
||
debugPrint('[停止作业] 最终 setState 完成,轨迹已清空');
|
||
}
|
||
|
||
debugPrint('══════════ [停止作业] 结束 ══════════');
|
||
_showPageToast(message: "作业已停止", type: ToastType.error);
|
||
}
|
||
|
||
/// 继续作业
|
||
void _resumeWork() async {
|
||
// 🔥 获取设备ID和taskId
|
||
final targetDevice = context.read<RemoteControlCubit>().state.targetDevice;
|
||
final deviceId = targetDevice?.deviceName;
|
||
if (deviceId == null || deviceId.isEmpty) {
|
||
_showPageToast(message: "请先选择一个设备", type: ToastType.info);
|
||
return;
|
||
}
|
||
|
||
final taskCubit = sl<DeviceTaskCubit>();
|
||
var taskId = taskCubit.state.currentTaskId;
|
||
|
||
// 🔥 taskId 为 null 时尝试从 SharedPreferences 恢复
|
||
if (taskId == null) {
|
||
debugPrint('📥 [继续作业] taskId 为空,尝试从本地恢复...');
|
||
taskId = await _restoreTaskIdFromLocal(deviceId);
|
||
if (taskId != null) {
|
||
taskCubit.updateCurrentTaskId(taskId);
|
||
debugPrint('📥 [继续作业] 从本地恢复 taskId: $taskId');
|
||
}
|
||
}
|
||
|
||
if (taskId == null) {
|
||
debugPrint('❌ [继续作业] taskId 为空,无法操控');
|
||
_showPageToast(message: "任务ID为空,无法操控", type: ToastType.warn);
|
||
return;
|
||
}
|
||
|
||
debugPrint('▶️ [继续作业] deviceId: $deviceId, taskId: $taskId');
|
||
|
||
setState(() {
|
||
_workStatus = WorkStatus.working;
|
||
});
|
||
_saveDataToLocal(); // 🔥 保存暂停状态
|
||
|
||
// 🔥 调用接口恢复任务(注释掉原有的 TCP 方式)
|
||
try {
|
||
await taskCubit.recoveryTask(deviceId);
|
||
_showPageToast(message: "作业已恢复", type: ToastType.success);
|
||
} catch (e) {
|
||
debugPrint('❌ [继续作业] 异常: $e');
|
||
_showPageToast(message: "恢复失败: $e", type: ToastType.error);
|
||
}
|
||
|
||
// 原有的 TCP 方式(已注释)
|
||
//context.read<DevicesCubit>().updateAppState(AppState.routePlanning);
|
||
//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);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 🔥 从 pathData 中实时解析 startWorkList,避免时序问题:
|
||
/// BlocBuilder 在 pathData 更新后立即渲染面板,但 startWorkList 在 onTap async 中
|
||
/// 稍后才赋值,导致面板先显示"路径区域为空"。此方法直接从 state.pathData 计算,
|
||
/// 确保面板渲染时数据已就绪。
|
||
List<dynamic> _parseStartWorkListFromPathData(
|
||
List<Map<String, dynamic>>? pathData,
|
||
) {
|
||
// debugPrint(
|
||
// '🔍 [_parseSWL] pathData 是否为null: ${pathData == null}, 长度: ${pathData?.length}',
|
||
// );
|
||
if (pathData == null || pathData.isEmpty) {
|
||
// debugPrint('❌ [_parseSWL] pathData 为空,返回 []');
|
||
return [];
|
||
}
|
||
|
||
final firstRecord = pathData.first;
|
||
// debugPrint('🔍 [_parseSWL] firstRecord keys: ${firstRecord.keys}');
|
||
final nestedJsonRaw = firstRecord['jsonData'];
|
||
// debugPrint('🔍 [_parseSWL] nestedJsonRaw 类型: ${nestedJsonRaw.runtimeType}');
|
||
|
||
// 🔥 兼容两种格式:String(需 jsonDecode)和 Map(已解析)
|
||
Map<String, dynamic> parsedJson;
|
||
if (nestedJsonRaw is String) {
|
||
try {
|
||
final decoded = jsonDecode(nestedJsonRaw);
|
||
if (decoded is! Map<String, dynamic>) {
|
||
// debugPrint('❌ [_parseSWL] jsonDecode 结果不是 Map,返回 []');
|
||
return [];
|
||
}
|
||
parsedJson = decoded;
|
||
} catch (_) {
|
||
// debugPrint('❌ [_parseSWL] jsonDecode 失败,返回 []');
|
||
return [];
|
||
}
|
||
} else if (nestedJsonRaw is Map<String, dynamic>) {
|
||
parsedJson = nestedJsonRaw;
|
||
} else {
|
||
// debugPrint(
|
||
// '❌ [_parseSWL] nestedJsonRaw 类型不支持: ${nestedJsonRaw.runtimeType},返回 []',
|
||
// );
|
||
return [];
|
||
}
|
||
|
||
// debugPrint('🔍 [_parseSWL] parsedJson keys: ${parsedJson.keys}');
|
||
|
||
final planModel = parsedJson['planModel'];
|
||
// 🔥 安全解析:支持 int 和 String 类型
|
||
final int planModelValue = int.tryParse(planModel?.toString() ?? '0') ?? 0;
|
||
// debugPrint(
|
||
// '🔍 [_parseSWL] planModel: $planModel, planModelValue: $planModelValue, WorkMode.bow.value: ${WorkMode.bow.value}',
|
||
// );
|
||
final isBow = planModelValue == WorkMode.bow.value;
|
||
|
||
List<dynamic> pathList = [];
|
||
final rawPath = parsedJson['path'];
|
||
if (rawPath is String) {
|
||
try {
|
||
pathList = jsonDecode(rawPath);
|
||
} catch (_) {}
|
||
} else if (rawPath is List) {
|
||
pathList = rawPath;
|
||
}
|
||
|
||
List<dynamic> outerList = [];
|
||
final rawOuter = parsedJson['outer'];
|
||
if (rawOuter is String) {
|
||
try {
|
||
outerList = jsonDecode(rawOuter);
|
||
} catch (_) {}
|
||
} else if (rawOuter is List) {
|
||
outerList = rawOuter;
|
||
}
|
||
|
||
if (isBow) {
|
||
final result = pathList.isNotEmpty ? pathList : outerList;
|
||
// debugPrint(
|
||
// '✅ [_parseSWL] 覆盖模式,pathList: ${pathList.length}, outerList: ${outerList.length}, 最终返回: ${result.length}',
|
||
// );
|
||
return result;
|
||
} else {
|
||
final result = outerList.isNotEmpty ? outerList : pathList;
|
||
// debugPrint(
|
||
// '✅ [_parseSWL] 自定义模式,outerList: ${outerList.length}, pathList: ${pathList.length}, 最终返回: ${result.length}',
|
||
// );
|
||
return result;
|
||
}
|
||
}
|
||
|
||
Widget _buildWorkPanel(headingStatus) {
|
||
return BlocBuilder<DevicesCubit, DevicesState>(
|
||
builder: (context, state) {
|
||
if (_selectedPlot == null || state.pathData == null) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
// 🔥 核心修复:从 state.pathData 实时计算 startWorkList
|
||
// 确保 BlocBuilder 触发时数据已就绪,不再依赖 onTap 的异步赋值时序
|
||
// debugPrint(
|
||
// '🔍 [_buildWorkPanel] BlocBuilder 触发, state.pathData 是否为空: ${state.pathData?.isEmpty ?? true}, startWorkList 当前长度: ${startWorkList.length}',
|
||
// );
|
||
final parsedList = _parseStartWorkListFromPathData(state.pathData);
|
||
if (parsedList.isNotEmpty) {
|
||
startWorkList = parsedList; // 同步到类字段,供 _saveDataToLocal 等方法使用
|
||
// debugPrint(
|
||
// '✅ [_buildWorkPanel] startWorkList 已更新,新长度: ${startWorkList.length}',
|
||
// );
|
||
} else {
|
||
// debugPrint(
|
||
// '⚠️ [_buildWorkPanel] parsedList 为空,startWorkList 保持: ${startWorkList.length}',
|
||
// );
|
||
}
|
||
|
||
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(
|
||
'${AppLocalizations.of(context).translate('route_planning.work_mode')}:$workMode',
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
color: Colors.grey,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
IconButton(
|
||
onPressed: _closeWorkPanel,
|
||
icon: const Icon(
|
||
Icons.close,
|
||
color: Colors.grey,
|
||
size: 20,
|
||
),
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(
|
||
minWidth: 24,
|
||
minHeight: 24,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
// 🔥 3. 当前任务信息显示(可点击重新选择)
|
||
BlocBuilder<DeviceTaskCubit, DeviceTaskState>(
|
||
builder: (context, taskState) {
|
||
final currentTask = taskState.currentTask;
|
||
if (currentTask == null) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
// 状态标签颜色
|
||
Color statusColor;
|
||
String statusText;
|
||
switch (currentTask.taskStatus) {
|
||
case 'NEW':
|
||
statusColor = Colors.blue;
|
||
statusText = '新建';
|
||
break;
|
||
case 'EXECUTING':
|
||
statusColor = Colors.green;
|
||
statusText = '执行中';
|
||
break;
|
||
case 'PAUSE':
|
||
statusColor = Colors.orange;
|
||
statusText = '暂停中';
|
||
break;
|
||
default:
|
||
statusColor = Colors.grey;
|
||
statusText = currentTask.taskStatusTranslate;
|
||
}
|
||
|
||
return GestureDetector(
|
||
onTap: () async {
|
||
// 🔥 点击刷新任务列表(调用查询任务接口)
|
||
final deviceId = currentTask.deviceId;
|
||
if (deviceId.isEmpty) {
|
||
_showPageToast(
|
||
message: "设备信息异常,无法刷新",
|
||
type: ToastType.warn,
|
||
);
|
||
return;
|
||
}
|
||
|
||
_showPageToast(
|
||
message: "正在刷新任务列表...",
|
||
type: ToastType.info,
|
||
);
|
||
|
||
final taskCubit = sl<DeviceTaskCubit>();
|
||
final previousTaskId = currentTask.id;
|
||
debugPrint(
|
||
'🔄 [刷新任务] previousTaskId=$previousTaskId, 开始查询...',
|
||
);
|
||
await taskCubit.fetchAndFilterTask(deviceId);
|
||
|
||
// 🔥 刷新后如果当前任务已不在查询结果中,关闭底部工作面板
|
||
final newActiveTasks = taskCubit.state.activeTasks;
|
||
final stillExists = newActiveTasks.any(
|
||
(t) => t.id == previousTaskId,
|
||
);
|
||
debugPrint(
|
||
'🔄 [刷新任务] 查询完成, 活跃任务数=${newActiveTasks.length}, stillExists=$stillExists',
|
||
);
|
||
if (!stillExists) {
|
||
_showPageToast(
|
||
message: "当前任务已不存在,已关闭面板",
|
||
type: ToastType.warn,
|
||
);
|
||
_closeWorkPanel();
|
||
} else {
|
||
_showPageToast(
|
||
message: "任务列表已刷新",
|
||
type: ToastType.success,
|
||
);
|
||
}
|
||
},
|
||
child: Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF5F7FA),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(
|
||
color: const Color(0xFF165DFF).withOpacity(0.2),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.assignment_outlined,
|
||
color: const Color(0xFF165DFF),
|
||
size: 20,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'当前任务: #${currentTask.id}',
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.bold,
|
||
color: Color(0xFF1D2129),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'设备: ${currentTask.deviceId}',
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
color: Color(0xFF4E5969),
|
||
),
|
||
softWrap: true,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 4),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 6,
|
||
vertical: 2,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: statusColor.withOpacity(0.1),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
statusText,
|
||
style: TextStyle(
|
||
color: statusColor,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Icon(
|
||
Icons.refresh,
|
||
color: const Color(0xFF165DFF),
|
||
size: 18,
|
||
),
|
||
const SizedBox(width: 4),
|
||
const Text(
|
||
'刷新',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: Color(0xFF165DFF),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
// 4. 错误提示(如果加载失败)
|
||
// 🔥 航向角拦截暂时注释,后续恢复
|
||
if (startWorkList.isEmpty)
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 路径为空提示 50%
|
||
if (startWorkList.isEmpty)
|
||
Expanded(
|
||
flex: 1,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 8,
|
||
),
|
||
margin: const EdgeInsets.only(right: 6, bottom: 12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.red[50],
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: Colors.red[200]!),
|
||
),
|
||
child: const Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(
|
||
Icons.error_outline,
|
||
color: Colors.redAccent,
|
||
size: 16,
|
||
),
|
||
SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
"路径区域为空",
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: Colors.redAccent,
|
||
),
|
||
softWrap: true,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
// 航向异常提示 50%
|
||
// 🔥 航向角拦截暂时注释,后续恢复
|
||
// if (headingStatus == 0)
|
||
// Expanded(
|
||
// flex: 1,
|
||
// child: Container(
|
||
// padding: const EdgeInsets.symmetric(
|
||
// horizontal: 12,
|
||
// vertical: 8,
|
||
// ),
|
||
// margin: const EdgeInsets.only(left: 6, bottom: 12),
|
||
// decoration: BoxDecoration(
|
||
// color: Colors.red[50],
|
||
// borderRadius: BorderRadius.circular(6),
|
||
// border: Border.all(color: Colors.red[200]!),
|
||
// ),
|
||
// child: const Row(
|
||
// crossAxisAlignment: CrossAxisAlignment.start,
|
||
// children: [
|
||
// Icon(
|
||
// Icons.error_outline,
|
||
// color: Colors.redAccent,
|
||
// size: 16,
|
||
// ),
|
||
// SizedBox(width: 8),
|
||
// Expanded(
|
||
// child: Text(
|
||
// "航向信息异常",
|
||
// style: TextStyle(
|
||
// fontSize: 14,
|
||
// color: Colors.redAccent,
|
||
// ),
|
||
// softWrap: true,
|
||
// maxLines: 2,
|
||
// overflow: TextOverflow.ellipsis,
|
||
// ),
|
||
// ),
|
||
// ],
|
||
// ),
|
||
// ),
|
||
// ),
|
||
],
|
||
),
|
||
|
||
// 3. 开始作业按钮(加载中禁用)
|
||
// 放在 Column/Row 的 children: [ ... ] 内部
|
||
_workStatus == WorkStatus.idle
|
||
? SizedBox(
|
||
width: double.infinity,
|
||
height: 50,
|
||
child: ElevatedButton(
|
||
onPressed: state.isLoading || startWorkList.isEmpty
|
||
// 🔥 航向角拦截暂时注释,后续恢复
|
||
// || headingStatus == 0
|
||
? 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,
|
||
),
|
||
)
|
||
: Text(
|
||
AppLocalizations.of(
|
||
context,
|
||
).translate('route_planning.start_work'),
|
||
style: const 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: _confirmStopWork,
|
||
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,
|
||
) {
|
||
debugPrint('📊 [_convertWorkRecordsToPlotData] 输入记录数: ${records.length}');
|
||
final result = records.map((record) {
|
||
final jd = record['jsonData'];
|
||
debugPrint(
|
||
' ├─ workName: ${record['workName']}, id: ${record['id']}, jsonData类型: ${jd?.runtimeType}, jsonData长度: ${jd is String ? jd.length : "N/A"}',
|
||
);
|
||
if (jd is String && jd.isNotEmpty) {
|
||
try {
|
||
final decoded = jsonDecode(jd);
|
||
if (decoded is Map) {
|
||
debugPrint(
|
||
' │ path类型: ${decoded['path']?.runtimeType}, outer类型: ${decoded['outer']?.runtimeType}, planModel: ${decoded['planModel']}',
|
||
);
|
||
if (decoded['path'] is List)
|
||
debugPrint(' │ path长度: ${(decoded['path'] as List).length}');
|
||
if (decoded['outer'] is List)
|
||
debugPrint(' │ outer长度: ${(decoded['outer'] as List).length}');
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
// 兼容 jsonData 为 Map 或 String 的情况:统一转为 JSON 字符串
|
||
String? jsonDataStr;
|
||
final rawJsonData = record['jsonData'];
|
||
if (rawJsonData is String) {
|
||
jsonDataStr = rawJsonData;
|
||
} else if (rawJsonData is Map) {
|
||
jsonDataStr = jsonEncode(rawJsonData);
|
||
}
|
||
|
||
return PlotData(
|
||
id:
|
||
record['id']?.toString() ??
|
||
DateTime.now().microsecondsSinceEpoch.toString(), // 唯一ID
|
||
plotName: record['workName'] ?? '未命名地块', // 地块名称(从接口字段取)
|
||
imageUrl: record['imgUrl'] ?? '', // 图片URL(从接口字段取,无则为空)
|
||
jsonData: jsonDataStr, // 原始数据的JSON字符串(可选,便于调试或后续使用)
|
||
);
|
||
}).toList();
|
||
debugPrint(
|
||
'📊 [_convertWorkRecordsToPlotData] 转换完成,输出地块数: ${result.length}',
|
||
);
|
||
return result;
|
||
}
|
||
|
||
/// 覆盖模式下闭合打点集合:把第一个点追加到末尾(A B C → A B C A)
|
||
/// 仅覆盖模式(WorkMode.bow)生效;点数不足 3 或首尾已重合时原样返回,避免重复追加
|
||
/// ⚠️ 只用于派生数据/出口数据,禁止写入 _markedPoints、_robotModeWgsPoints 源集合
|
||
/// 🔥 每个分支都打日志,便于确认“第一个点到底有没有被追加到末尾”
|
||
List<LatLng> _closeBowRing(List<LatLng> points) {
|
||
if (_currentWorkMode != WorkMode.bow) {
|
||
debugPrint(
|
||
'🔗 [闭合] ⏭️ 跳过追加:workMode=$_currentWorkMode 不是覆盖模式,'
|
||
'点数=${points.length} 原样返回(不闭合)',
|
||
);
|
||
return List<LatLng>.from(points);
|
||
}
|
||
if (points.length < 3) {
|
||
debugPrint(
|
||
'🔗 [闭合] ⏭️ 跳过追加:点数=${points.length} 不足 3 个,无法成环,'
|
||
'原样返回(避免造出退化多边形)',
|
||
);
|
||
return List<LatLng>.from(points);
|
||
}
|
||
final LatLng first = points.first;
|
||
final LatLng last = points.last;
|
||
if (first.latitude == last.latitude && first.longitude == last.longitude) {
|
||
debugPrint(
|
||
'🔗 [闭合] ⏭️ 跳过追加:首尾已经重合'
|
||
'(lat=${first.latitude}, lng=${first.longitude}),'
|
||
'点数=${points.length} 保持不变(幂等保护,不重复叠加)',
|
||
);
|
||
return List<LatLng>.from(points);
|
||
}
|
||
// ✅ 真正执行追加的地方
|
||
debugPrint(
|
||
'🔗 [闭合] ✅ 已把第一个点追加到末尾!'
|
||
'追加的坐标=第[0]个点(lat=${first.latitude}, lng=${first.longitude});'
|
||
'它成为新的第[${points.length}]号点(即最后一个点);'
|
||
'点数 ${points.length} → ${points.length + 1};'
|
||
'原本的第[${points.length - 1}]号点(lat=${last.latitude}, lng=${last.longitude}) 往前让一位',
|
||
);
|
||
debugPrint(
|
||
'🔗 [闭合] ✅ 追加后首尾关系:新[0]=(lat=${first.latitude}, lng=${first.longitude}) '
|
||
'== 新[${points.length}]=(lat=${first.latitude}, lng=${first.longitude}) → 完全相同',
|
||
);
|
||
return [...points, LatLng(first.latitude, first.longitude)];
|
||
}
|
||
|
||
// ========== 抽象:生成路径的核心函数(打点函数) ==========
|
||
Future<void> _generatePath({bool showTips = true}) async {
|
||
debugPrint(
|
||
'🛤️ [生成路径] 作业模式: $_currentWorkMode, 机器人模式: $_currentRobotMode, 打点数: ${_markedPoints.length}, 行距: $_workDistance, 航向角: $_angle',
|
||
);
|
||
if (_currentWorkMode == WorkMode.custom) {
|
||
// 🔥 对齐 Web 端:自定义模式 path=打点, outer=[]
|
||
debugPrint(
|
||
'📌 [生成路径] 自定义模式: path点数=${_markedPoints.length}, outer=[], planModel=2',
|
||
);
|
||
setState(() {
|
||
_saveBoxOpen = true;
|
||
_isWorkAreaCompleted = true;
|
||
gcjPathPoints = List.from(_markedPoints);
|
||
// gcjOuterPoints = List.from(_markedPoints); // 旧逻辑:outer也存打点
|
||
gcjOuterPoints = []; // 对齐 Web 端:自定义模式 outer 为空
|
||
typedPathList = [];
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (_currentAreaMode == AreaMode.work && _markedPoints.isEmpty) {
|
||
if (showTips) {
|
||
_showPageToast(message: '请先添加作业区域打点!', type: ToastType.error);
|
||
}
|
||
return;
|
||
}
|
||
if (_currentAreaMode == AreaMode.work && _markedPoints.length < 3) {
|
||
if (showTips) {
|
||
_showPageToast(message: '作业区域至少需要3个打点才能生成路径!', type: ToastType.error);
|
||
}
|
||
return;
|
||
}
|
||
if (_currentWorkMode == null) {
|
||
if (showTips) {
|
||
_showPageToast(message: '请先选择作业模式!', type: ToastType.error);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 覆盖模式行距默认为0.0,与Web端保持一致
|
||
|
||
try {
|
||
LatLng firstPointWgs;
|
||
if (_currentRobotMode == RobotMode.robot &&
|
||
_robotModeWgsPoints.isNotEmpty) {
|
||
firstPointWgs = _robotModeWgsPoints.first;
|
||
} else if (_currentRobotMode == RobotMode.point &&
|
||
_markedPoints.isNotEmpty) {
|
||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||
firstPointWgs = _markedPoints.first;
|
||
} else if (_markedPoints.isNotEmpty) {
|
||
// 兜底:_robotModeWgsPoints 为空但_markedPoints有数据(新缓存key未写入等场景)
|
||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||
firstPointWgs = _markedPoints.first;
|
||
} else {
|
||
if (showTips) {
|
||
_showPageToast(message: '参考点坐标为空,请先添加作业区域打点!', type: ToastType.error);
|
||
}
|
||
return;
|
||
}
|
||
|
||
final referencePoint = work_area_model.ReferencePoint(
|
||
lat: firstPointWgs.latitude,
|
||
lon: firstPointWgs.longitude,
|
||
);
|
||
final heading = _angle == -1 ? -1 : _angle.toInt();
|
||
|
||
List<work_area_model.Position> outerPositions = [];
|
||
if (_currentRobotMode == RobotMode.robot &&
|
||
_robotModeWgsPoints.isNotEmpty) {
|
||
outerPositions = _closeBowRing(_robotModeWgsPoints).map((latLng) {
|
||
return work_area_model.Position(
|
||
lat: latLng.latitude,
|
||
lon: latLng.longitude,
|
||
);
|
||
}).toList();
|
||
} else if (_markedPoints.isNotEmpty) {
|
||
// robot模式但_robotModeWgsPoints为空时,用_markedPoints(已是WGS84)
|
||
outerPositions = _closeBowRing(_markedPoints).map((latLng) {
|
||
return work_area_model.Position(
|
||
lat: latLng.latitude,
|
||
lon: latLng.longitude,
|
||
);
|
||
}).toList();
|
||
}
|
||
gcjOuterPoints = outerPositions.map((point) {
|
||
double wgs84Lat = point.lat;
|
||
double wgs84Lon = point.lon;
|
||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||
return LatLng(wgs84Lat, wgs84Lon);
|
||
}).toList();
|
||
|
||
final outerBoundary = work_area_model.OuterBoundary(
|
||
position: outerPositions,
|
||
sideWidth: _workDistance,
|
||
);
|
||
work_area_model.HoleBoundary? holeBoundary;
|
||
|
||
if (_obstacleHoles.isNotEmpty) {
|
||
List<List<work_area_model.Position>> positionList = [];
|
||
|
||
if (_currentRobotMode == RobotMode.robot &&
|
||
_robotModeObsWgsPoints.isNotEmpty) {
|
||
for (var holePoints in _obstacleWgsHoles) {
|
||
List<work_area_model.Position> innerPositionList = [];
|
||
innerPositionList = holePoints.map((latLng) {
|
||
return work_area_model.Position(
|
||
lat: latLng.latitude,
|
||
lon: latLng.longitude,
|
||
);
|
||
}).toList();
|
||
positionList.add(innerPositionList);
|
||
}
|
||
} else if (_currentRobotMode == RobotMode.point &&
|
||
_obstacleHoles.isNotEmpty) {
|
||
for (var holePoints in _obstacleHoles) {
|
||
List<work_area_model.Position> innerPositionList = [];
|
||
innerPositionList = holePoints.map((latLng) {
|
||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||
return work_area_model.Position(
|
||
lat: latLng.latitude,
|
||
lon: latLng.longitude,
|
||
);
|
||
}).toList();
|
||
positionList.add(innerPositionList);
|
||
}
|
||
}
|
||
|
||
holeBoundary = work_area_model.HoleBoundary(
|
||
position: positionList,
|
||
sideWidth: 0.0,
|
||
);
|
||
}
|
||
|
||
Map<String, work_area_model.HoleBoundary> holes = {};
|
||
if (holeBoundary != null) {
|
||
holes = {'tempKey': holeBoundary};
|
||
}
|
||
final workType = _currentWorkMode == WorkMode.bow ? 0 : 2;
|
||
|
||
debugPrint(
|
||
'🔍 [生成路径-发送] referencePoint=(${referencePoint.lat}, ${referencePoint.lon}), heading=$heading, workType=$workType',
|
||
);
|
||
debugPrint(
|
||
'🔍 [生成路径-发送] outerPositions点数=${outerPositions.length}, sideWidth=$_workDistance',
|
||
);
|
||
for (var i = 0; i < outerPositions.length; i++) {
|
||
debugPrint(
|
||
'🔍 [生成路径-发送] outer[$i]=(${outerPositions[i].lat}, ${outerPositions[i].lon})',
|
||
);
|
||
}
|
||
|
||
// 🔥 已废弃:不再调用旧的路径生成API,path只传用户手动打点的边界点
|
||
/*
|
||
await context.read<DevicesCubit>().generatePath(
|
||
reference: referencePoint,
|
||
heading: heading,
|
||
outer: outerBoundary,
|
||
holes: holes.isNotEmpty ? holes : {},
|
||
workType: workType,
|
||
);
|
||
|
||
final cubitState = context.read<DevicesCubit>().state;
|
||
|
||
if (cubitState.errorMessage != null) {
|
||
debugPrint('❌ [生成路径] 失败: ${cubitState.errorMessage}');
|
||
if (showTips) {
|
||
_showPageToast(
|
||
message: '路径生成失败:${cubitState.errorMessage}',
|
||
type: ToastType.error,
|
||
);
|
||
}
|
||
} else if (cubitState.generatedPath != null) {
|
||
if (showTips) {
|
||
_showPageToast(message: '路径生成成功!', type: ToastType.success);
|
||
}
|
||
debugPrint(
|
||
'✅ [生成路径] 成功,路径点数: ${(cubitState.generatedPath as List).length}',
|
||
);
|
||
if (cubitState.generatedPath is List) {
|
||
setState(() {
|
||
_isWorkAreaCompleted = true;
|
||
_saveBoxOpen = true;
|
||
_currentObstaclePoints.clear();
|
||
_robotModeObsWgsPoints.clear();
|
||
final pathList = cubitState.generatedPath as List;
|
||
typedPathList = pathList
|
||
.whereType<work_area_model.DeviceAddPathPointModel>()
|
||
.toList();
|
||
gcjPathPoints = typedPathList.map((point) {
|
||
double wgs84Lat = point.latitude;
|
||
double wgs84Lon = point.longitude;
|
||
final gcjPoint = wgs84ToGcj02(wgs84Lat, wgs84Lon);
|
||
return LatLng(gcjPoint.latitude, gcjPoint.longitude);
|
||
}).toList();
|
||
});
|
||
_saveDataToLocal();
|
||
moveMapToPointsCenter(gcjPathPoints);
|
||
}
|
||
}
|
||
*/
|
||
|
||
// 🔥 修复:旧路径生成API已废弃,覆盖模式直接用打点坐标,打开保存框
|
||
debugPrint(
|
||
'📌 [生成路径] 覆盖模式: path点数=${_markedPoints.length}, outer=打点坐标, planModel=0',
|
||
);
|
||
setState(() {
|
||
_saveBoxOpen = true;
|
||
_isWorkAreaCompleted = true;
|
||
_currentObstaclePoints.clear();
|
||
_robotModeObsWgsPoints.clear();
|
||
// 🔥 覆盖模式:闭合边界(A B C → A B C A),保证地图显示与保存截图一致
|
||
gcjPathPoints = _closeBowRing(_markedPoints);
|
||
typedPathList = [];
|
||
});
|
||
_saveDataToLocal();
|
||
moveMapToPointsCenter(gcjPathPoints);
|
||
} catch (e) {
|
||
debugPrint('路径生成异常:$e');
|
||
if (showTips) {
|
||
_showPageToast(
|
||
message: '路径生成异常:${e.toString().substring(0, 50)}',
|
||
type: ToastType.error,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
void _showPageToast({required String message, required ToastType type}) {
|
||
_toastQueue.add(_ToastItem(message: message, type: type));
|
||
if (!_isToastShowing) {
|
||
_processNextToast();
|
||
}
|
||
}
|
||
|
||
void _processNextToast() {
|
||
if (_toastQueue.isEmpty) {
|
||
_isToastShowing = false;
|
||
return;
|
||
}
|
||
|
||
_isToastShowing = true;
|
||
final item = _toastQueue.removeAt(0);
|
||
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
|
||
OverlayEntry entry = OverlayEntry(
|
||
builder: (context) => SafeArea(
|
||
child: Align(
|
||
alignment: Alignment.topCenter,
|
||
child: Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 30),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: _getToastColor(item.type),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Text(
|
||
item.message,
|
||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
_toastEntries.add(entry);
|
||
Overlay.of(context)?.insert(entry);
|
||
|
||
Future.delayed(const Duration(seconds: 2), () {
|
||
if (entry.mounted) entry.remove();
|
||
_toastEntries.remove(entry);
|
||
_processNextToast();
|
||
});
|
||
});
|
||
}
|
||
|
||
void _cancelAllToast() {
|
||
for (final entry in _toastEntries) {
|
||
if (entry.mounted) {
|
||
entry.remove();
|
||
}
|
||
}
|
||
_toastEntries.clear();
|
||
_toastQueue.clear();
|
||
_isToastShowing = false;
|
||
}
|
||
|
||
Color _getToastColor(ToastType type) {
|
||
switch (type) {
|
||
case ToastType.success:
|
||
return Colors.green;
|
||
case ToastType.error:
|
||
return Colors.red;
|
||
case ToastType.warn:
|
||
return Colors.orange;
|
||
case ToastType.info:
|
||
return Colors.blue;
|
||
case ToastType.loading:
|
||
return Colors.black87;
|
||
}
|
||
}
|
||
|
||
@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;
|
||
// 🔥 V2 适配:使用 RemoteControlCubit 的 targetDevice 保持一致性
|
||
final deviceId = context
|
||
.watch<RemoteControlCubit>()
|
||
.state
|
||
.targetDevice
|
||
?.deviceName;
|
||
|
||
if (deviceId != null &&
|
||
userState.user != null &&
|
||
userState.user!.token != null) {
|
||
_videoStreamUrl =
|
||
"webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=${userState.user!.token}";
|
||
} else {
|
||
_videoStreamUrl = '';
|
||
}
|
||
return BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
|
||
builder: (context, state) {
|
||
// 🔥 修复:使用 read 而非 watch,避免不必要的重建和接口调用
|
||
final devicesState = context.read<DevicesCubit>().state;
|
||
bool isFinishWork = devicesState.isFinshWork ?? false;
|
||
double? arriLatitude = devicesState.arriLatitude;
|
||
double? arriLongitude = devicesState.arriLongitude;
|
||
LatLng? targetLatLng;
|
||
if (arriLatitude != null && arriLongitude != null) {
|
||
targetLatLng = LatLng(arriLatitude, arriLongitude);
|
||
_logger.log(
|
||
"当前轨迹模式: ${_traceManager.getMode()},收到完成点坐标: $targetLatLng",
|
||
);
|
||
if (__isValidLatLng(targetLatLng)) {
|
||
//debugPrint("${targetLatLng}收到完成点arriLatitude");
|
||
//if (!isreceiveFirstCompletePoint && isStartWork && _workStatus == WorkStatus.working) {
|
||
debugPrint("收到完成点arriLatitude");
|
||
if (_traceManager.getMode() != TPMode.NAVIGATION) {
|
||
_traceManager.setMode(TPMode.NAVIGATION);
|
||
debugPrint("切换到导航模式");
|
||
}
|
||
|
||
isreceiveFirstCompletePoint = true;
|
||
//}
|
||
if (isStartWork && _workStatus == WorkStatus.working) {
|
||
_traceManager.upsert(targetLatLng, TPAction.ADD);
|
||
}
|
||
}
|
||
}
|
||
|
||
double currentLat = 0.0;
|
||
double currentLng = 0.0;
|
||
bool obfFlag = false; //障碍物标志位
|
||
int headingStatus = 0;
|
||
String controlMode = "0";
|
||
DeviceStatusUpdated? updatedState;
|
||
//有停止信号
|
||
if (isFinishWork) {
|
||
// 🔥 清除 taskId 持久化数据(任务已完成)
|
||
final finishDeviceId = context
|
||
.read<RemoteControlCubit>()
|
||
.state
|
||
.targetDevice
|
||
?.deviceName;
|
||
if (finishDeviceId != null) {
|
||
sl<DeviceTaskCubit>().clearCurrentTask();
|
||
_clearTaskIdFromLocal(finishDeviceId);
|
||
debugPrint('🏁 [完成] 任务已到达终点,清除 taskId: deviceId=$finishDeviceId');
|
||
}
|
||
// 🔥 关键:用微任务延迟执行状态更新,避开构建阶段
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_workStatus = WorkStatus.idle;
|
||
isStopWork = true;
|
||
isreceiveFirstCompletePoint = false;
|
||
|
||
tracePoint = [];
|
||
gctracePoint = [];
|
||
tracePoint?.clear();
|
||
gctracePoint?.clear();
|
||
});
|
||
_showPageToast(message: "作业已完成", type: ToastType.success);
|
||
|
||
// 延迟重置轨迹管理器
|
||
Future.delayed(const Duration(seconds: 1), () {
|
||
if (mounted) {
|
||
_traceManager.setMode(TPMode.LOCATION);
|
||
|
||
_traceManager.reset();
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
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("路径规划四十数据 - 经纬度:");
|
||
//debugPrint(" RunningStatus纬度:${updatedState.status.latitude}");
|
||
//debugPrint(" RunningStatus经度:${updatedState.status.longitude}");
|
||
// debugPrint("障碍物${updatedState.status.obstacleFlag}");
|
||
// debugPrint("初始化${updatedState.status.headingStatus}");
|
||
|
||
// 提取经纬度并更新本地变量
|
||
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,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
/// 悬浮“回到当前场站”按钮:一键跳转到选中场站高清图层(zoom 18 才看得清小地块)
|
||
Positioned(
|
||
top: 60,
|
||
left: 10,
|
||
child: GestureDetector(
|
||
onTap: () =>
|
||
_mapController.move(_getDefaultValidCenter(), 18),
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
alignment: Alignment.center,
|
||
child: const Text(
|
||
'站',
|
||
style: TextStyle(
|
||
color: Colors.black87,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
/// 悬浮“刷新高清图层”按钮:后端改了图层/影像/场站映射后,
|
||
/// 点它强制拉最新(清瓦片缓存 + 重拉场站接口 + 重拉 GetCapabilities + 重新定位)
|
||
Positioned(
|
||
top: 110,
|
||
left: 10,
|
||
child: GestureDetector(
|
||
onTap: _onRefreshStationLayers,
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
alignment: Alignment.center,
|
||
child: const Icon(
|
||
Icons.refresh,
|
||
color: Colors.black87,
|
||
size: 22,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
/// 核心:地图正中心固定定位标
|
||
if (_currentRobotMode == RobotMode.point)
|
||
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(
|
||
selectedWorkModeIn: _currentWorkMode ?? WorkMode.bow,
|
||
onEditTap: (bool isOpen) {
|
||
setState(() {
|
||
_isPanelOpen = isOpen;
|
||
});
|
||
},
|
||
onListBox: (bool isOpen) {
|
||
// 🔥 使用新接口:根据场站ID加载作业记录
|
||
final selectedSite = sl<SiteCubit>().state.selectedSite;
|
||
if (selectedSite == null) {
|
||
_showPageToast(
|
||
message: '请先选择场站',
|
||
type: ToastType.error,
|
||
);
|
||
return;
|
||
}
|
||
print(
|
||
'加载作业记录,当前场站ID:${selectedSite.id},场站名称:${selectedSite.siteName}',
|
||
);
|
||
context.read<DevicesCubit>().loadWorkRecordsBySiteId(
|
||
selectedSite.id,
|
||
);
|
||
debugPrint(
|
||
'📋 [路径规划] 作业列表加载请求已发送, siteId=${selectedSite.id}',
|
||
);
|
||
|
||
setState(() {
|
||
_isListBoxOpen = isOpen;
|
||
_isWorkPanelOpen = false;
|
||
});
|
||
_saveDataToLocal();
|
||
},
|
||
onDjShow: (bool isOpen) {
|
||
if (isOpen) context.push(RoutePaths.djMap);
|
||
setState(() {
|
||
isDjMapShow = isOpen;
|
||
});
|
||
},
|
||
|
||
onWorkModeSelected: (mode) {
|
||
debugPrint('外部收到作业模式选择:$mode');
|
||
setState(() {
|
||
_currentWorkMode = mode;
|
||
_saveDataToLocal();
|
||
});
|
||
|
||
debugPrint('外部收到作业模式:$mode');
|
||
},
|
||
handleGotoLocation: _handleGotoLocation,
|
||
onRefreshTap: _handleRefresh, // 推荐用局部刷新
|
||
onVideoTap: _handleVideo,
|
||
),
|
||
),
|
||
),
|
||
|
||
// 底部操作面板
|
||
if (_isPanelOpen)
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: BottomOperationPanel(
|
||
initialRobotMode: _currentRobotMode,
|
||
initialAreaMode: _currentAreaMode,
|
||
initialWorkMode: _currentWorkMode!,
|
||
isShowButton: _currentWorkMode == WorkMode.bow
|
||
? true
|
||
: false,
|
||
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: () {
|
||
_undoAction();
|
||
},
|
||
onDeleteTap: () {
|
||
_deleteAllAction();
|
||
},
|
||
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;
|
||
});
|
||
},
|
||
onLandTap: () {
|
||
debugPrint('外部处理地块标签点击');
|
||
},
|
||
onRouteTap: () {
|
||
debugPrint('外部处理航线标签点击');
|
||
},
|
||
),
|
||
),
|
||
|
||
// 航线方向面板
|
||
if (_directionBoxOpen)
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: RouteDirectionPanel(
|
||
initialOptimalHeading: true,
|
||
initialDirection: 0.0,
|
||
titleText: AppLocalizations.of(
|
||
context,
|
||
).translate('route_planning.route_direction'),
|
||
optimalHeadingText: AppLocalizations.of(
|
||
context,
|
||
).translate('route_planning.optimal_heading'),
|
||
routeDirectionText: AppLocalizations.of(
|
||
context,
|
||
).translate('route_planning.route_direction'),
|
||
onValueChanged: (result) {
|
||
_angle = result['optimalHeading'] == true
|
||
? -1
|
||
: result['direction'];
|
||
|
||
if (_markedPoints.length >= 3 &&
|
||
_currentWorkMode != null) {
|
||
_generatePath(showTips: false);
|
||
}
|
||
},
|
||
onCancel: () {
|
||
setState(() => _directionBoxOpen = false);
|
||
},
|
||
),
|
||
),
|
||
|
||
// 列表面板 - 使用BlocBuilder监听DevicesCubit状态
|
||
if (_isListBoxOpen)
|
||
BlocBuilder<DevicesCubit, DevicesState>(
|
||
builder: (context, state) {
|
||
final plotList = _convertWorkRecordsToPlotData(
|
||
state.workRecords ?? [],
|
||
);
|
||
debugPrint(
|
||
'📋 [路径规划] 列表面板渲染: 作业记录=${state.workRecords?.length ?? 0}, 转换后地块数=${plotList.length}',
|
||
);
|
||
|
||
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(
|
||
AppLocalizations.of(context)
|
||
.translate(
|
||
'route_planning.plot_list_title',
|
||
)
|
||
.replaceAll(
|
||
'%d',
|
||
plotList.length.toString(),
|
||
),
|
||
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
|
||
? Center(
|
||
child: Column(
|
||
mainAxisAlignment:
|
||
MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(
|
||
Icons.inbox_outlined,
|
||
color: Colors.grey,
|
||
size: 48,
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
AppLocalizations.of(
|
||
context,
|
||
).translate(
|
||
'route_planning.no_plot_data',
|
||
),
|
||
style: const 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,
|
||
);
|
||
// 🔥 修复:删除后按“当前全局场站”重查,与打开列表(onListBox)走同一接口 selectBySiteId
|
||
// 原逻辑用 loadWorkRecords(userId) 走 selectByUserId(按登录用户查),
|
||
// 与列表口径不一致,导致删除后重查的数据不对;重新点列表才恢复正常。
|
||
final selectedSite =
|
||
sl<SiteCubit>()
|
||
.state
|
||
.selectedSite;
|
||
if (selectedSite != null) {
|
||
await context
|
||
.read<DevicesCubit>()
|
||
.loadWorkRecordsBySiteId(
|
||
selectedSite.id,
|
||
);
|
||
}
|
||
|
||
if (mounted) setState(() {});
|
||
});
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
|
||
// 🔥 航向角拦截暂时注释,后续恢复
|
||
// if (_showHeadingWarn)
|
||
// Positioned(
|
||
// top: 100, // 调整位置,避免遮挡核心内容
|
||
// left: 20,
|
||
// right: 20,
|
||
// child: Container(
|
||
// padding: const EdgeInsets.symmetric(
|
||
// horizontal: 16,
|
||
// vertical: 12,
|
||
// ),
|
||
// decoration: BoxDecoration(
|
||
// color: Colors.orange.withOpacity(0.9),
|
||
// borderRadius: BorderRadius.circular(8),
|
||
// boxShadow: [
|
||
// BoxShadow(
|
||
// color: Colors.black12,
|
||
// blurRadius: 4,
|
||
// offset: const Offset(0, 2),
|
||
// ),
|
||
// ],
|
||
// ),
|
||
// child: Row(
|
||
// children: [
|
||
// const Icon(
|
||
// Icons.warning_amber_rounded,
|
||
// color: Colors.white,
|
||
// size: 20,
|
||
// ),
|
||
// const SizedBox(width: 8),
|
||
// Expanded(
|
||
// child: Text(
|
||
// AppLocalizations.of(
|
||
// context,
|
||
// ).translate('route_planning.heading_not_init'),
|
||
// style: const TextStyle(
|
||
// color: Colors.white,
|
||
// fontSize: 14,
|
||
// ),
|
||
// ),
|
||
// ),
|
||
// ],
|
||
// ),
|
||
// ),
|
||
// ),
|
||
|
||
// 2. 远程模式提示(已关闭,用户要求去掉)
|
||
// if (_showControlModeWarn)
|
||
// Positioned(
|
||
// top: 160,
|
||
// left: 20,
|
||
// right: 20,
|
||
// child: Container(
|
||
// padding: const EdgeInsets.symmetric(
|
||
// horizontal: 16,
|
||
// vertical: 12,
|
||
// ),
|
||
// decoration: BoxDecoration(
|
||
// color: Colors.red.withOpacity(0.9),
|
||
// borderRadius: BorderRadius.circular(8),
|
||
// boxShadow: [
|
||
// BoxShadow(
|
||
// color: Colors.black12,
|
||
// blurRadius: 4,
|
||
// offset: const Offset(0, 2),
|
||
// ),
|
||
// ],
|
||
// ),
|
||
// child: Row(
|
||
// children: [
|
||
// const Icon(
|
||
// Icons.error_rounded,
|
||
// color: Colors.white,
|
||
// size: 20,
|
||
// ),
|
||
// const SizedBox(width: 8),
|
||
// Expanded(
|
||
// child: Text(
|
||
// AppLocalizations.of(
|
||
// context,
|
||
// ).translate('route_planning.switch_remote_mode'),
|
||
// style: const TextStyle(
|
||
// color: Colors.white,
|
||
// fontSize: 14,
|
||
// ),
|
||
// ),
|
||
// ),
|
||
// ],
|
||
// ),
|
||
// ),
|
||
// ),
|
||
if (_isWorkPanelOpen) _buildWorkPanel(headingStatus),
|
||
if (_isVideoDialogOpen) _buildVideoPopup(),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// ⚠️ 以下函数已注释 - 地图已切换为 ESRI WGS84,不再需要 GCJ02 转换
|
||
// 后续确认无误后可删除
|
||
// ⚠️ convertWGS84ToGCJ02 函数已删除 - 地图已切换为 ESRI WGS84,不再需要 GCJ02 转换
|
||
// 以下坐标转换函数保留但注释,后续确认无误后可删除
|
||
|
||
// ⚠️ 坐标转换函数已注释 - 地图已切换为 ESRI WGS84,不再需要 GCJ02 转换
|
||
// 以下函数保留但注释,后续确认无误后可删除
|
||
/*
|
||
/// 坐标转换:WGS84 -> GCJ02(国内高德/腾讯通用)
|
||
/// =======================================================
|
||
const double _pi = 3.1415926535897932384626;
|
||
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 bd09ToGcj02(dynamic bdLng, dynamic bdLat) {
|
||
// 1. 先将字符串转为double(兼容你的初始值格式)
|
||
double lng = _safeToDouble(bdLng);
|
||
double lat = _safeToDouble(bdLat);
|
||
|
||
// 2. BD09转GCJ02核心公式
|
||
double x = lng - 0.0065;
|
||
double y = lat - 0.006;
|
||
double z = math.sqrt(x * x + y * y) - 0.00002 * math.sin(y * _pi);
|
||
double theta = math.atan2(y, x) - 0.000003 * math.cos(x * _pi);
|
||
double gcjLng = z * math.cos(theta);
|
||
double gcjLat = z * math.sin(theta);
|
||
|
||
return LatLng(gcjLat, gcjLng);
|
||
}
|
||
|
||
double _safeToDouble(dynamic value) {
|
||
if (value == null) return 0.0;
|
||
if (value is double) return value;
|
||
if (value is int) return value.toDouble();
|
||
if (value is String) {
|
||
// 字符串转数字,失败返回0.0
|
||
return double.tryParse(value) ?? 0.0;
|
||
}
|
||
return 0.0;
|
||
}
|
||
|
||
LatLng gcj02ToWgs84(double lat, double lon, {int iterations = 10}) {
|
||
//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);
|
||
if (_outOfChina(lat, lon)) {
|
||
return LatLng(lat, lon);
|
||
}
|
||
|
||
double initLat = lat;
|
||
double initLon = lon;
|
||
double delta = 1e-6; // 迭代精度(约0.1米)
|
||
|
||
for (int i = 0; i < iterations; i++) {
|
||
// 1. 用当前猜测值计算GCJ02坐标
|
||
LatLng gcjGuess = wgs84ToGcj02(initLat, initLon);
|
||
// 2. 计算与原始GCJ02的差值
|
||
double dLat = gcjGuess.latitude - lat;
|
||
double dLon = gcjGuess.longitude - lon;
|
||
// 3. 修正猜测值
|
||
initLat -= dLat;
|
||
initLon -= dLon;
|
||
// 4. 精度达标则提前退出
|
||
if (sqrt(dLat * dLat + dLon * dLon) < delta) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return LatLng(initLat, initLon);
|
||
}
|
||
|
||
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;
|
||
}
|
||
*/
|
||
|
||
/// 计算两个经纬度点之间的地面距离(单位:米,GCJ02/WGS84 通用)
|
||
String _calculateLatLngDistance(LatLng point1, LatLng point2) {
|
||
const double earthRadius = 6371000; // 地球半径(米)
|
||
|
||
// 经纬度转弧度
|
||
double lat1Rad = point1.latitude * pi / 180;
|
||
double lat2Rad = point2.latitude * pi / 180;
|
||
double lng1Rad = point1.longitude * pi / 180;
|
||
double lng2Rad = point2.longitude * pi / 180;
|
||
|
||
// 哈维正弦公式(Haversine)计算地球表面两点实际距离
|
||
double dLat = lat2Rad - lat1Rad;
|
||
double dLng = lng2Rad - lng1Rad;
|
||
double a =
|
||
sin(dLat / 2) * sin(dLat / 2) +
|
||
cos(lat1Rad) * cos(lat2Rad) * sin(dLng / 2) * sin(dLng / 2);
|
||
double c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||
double distance = earthRadius * c;
|
||
|
||
// 格式化显示:短距离显示米,长距离显示千米
|
||
if (distance < 1000) {
|
||
return '${distance.toStringAsFixed(1)} 米';
|
||
} else {
|
||
return '${(distance / 1000).toStringAsFixed(2)} 千米';
|
||
}
|
||
}
|
||
|
||
/// 获取两个坐标点的中点坐标(用于显示距离文本)
|
||
LatLng _getMiddleLatLng(LatLng point1, LatLng point2) {
|
||
double middleLat = (point1.latitude + point2.latitude) / 2;
|
||
double middleLng = (point1.longitude + point2.longitude) / 2;
|
||
return LatLng(middleLat, middleLng);
|
||
}
|
||
|
||
// 🔥 万能格式化打印:输出完整结构(层级+字段名+值)
|
||
String formatPrettyJson(dynamic obj) {
|
||
try {
|
||
if (obj == null) return 'null';
|
||
if (obj is List) {
|
||
return obj.map((e) => formatPrettyJson(e)).toList().toString();
|
||
}
|
||
if (obj is Map) {
|
||
Map<String, dynamic> map = {};
|
||
obj.forEach((k, v) {
|
||
map[k.toString()] = formatPrettyJson(v);
|
||
});
|
||
return map.toString();
|
||
}
|
||
// 自定义模型:转 Map
|
||
try {
|
||
return obj.toJson().toString();
|
||
} catch (e) {
|
||
return obj.toString();
|
||
}
|
||
} catch (e) {
|
||
return obj.toString();
|
||
}
|
||
}
|