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

5308 lines
210 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:math';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package: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();
}
}