Files
flutterApp/lib/features/home/presentation/widgets/map/testmap_pages.dart
2026-08-07 08:49:29 +08:00

4622 lines
180 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: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/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/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/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();
// 初始化轨迹管理器(泛型指定为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;
@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;
});
}
}
});
debugPrint('✅ [MapPage] initState 完成');
}
// 🔥 核心修复:监听路径规划应答流
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) {
gcjOuterPoints = List.from(_markedPoints);
}
// 2. 关键:同步机器人模式坐标(保存时边框不丢失)
if (_currentRobotMode == RobotMode.point && _robotModeWgsPoints.isEmpty) {
_robotModeWgsPoints = _markedPoints.map((p) {
return gcj02ToWgs84(p.latitude, p.longitude);
}).toList();
}
// 兜底:Robot模式下_robotModeWgsPoints缓存未写入时,从_markedPoints恢复
if (_currentRobotMode == RobotMode.robot &&
_robotModeWgsPoints.isEmpty &&
_markedPoints.isNotEmpty) {
_robotModeWgsPoints = _markedPoints.map((p) {
return gcj02ToWgs84(p.latitude, p.longitude);
}).toList();
}
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) {
if (points.isEmpty || !mounted) return;
// 计算边界
final bounds = calculateBounds(points);
if (bounds == null) return;
final currentZoom = _mapController.camera?.zoom ?? 18.0;
_mapController.move(
bounds.center, // 边界中心点
currentZoom, // 保留当前缩放比例
);
print('地图已移动到绘制内容中心,边界范围:$bounds');
}
// ========== 新增:安全转换任意类型为double ==========
double? safeToDouble(dynamic value) {
if (value == null) return null;
if (value is double) return value;
if (value is int) return value.toDouble();
if (value is String) {
return double.tryParse(value); // 字符串转数字(失败返回null)
}
return null;
}
bool __isValidLatLng(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)',
);
});
}
}
}
Future<void> _savePlotData(String plotName, String? imgBase64) async {
debugPrint('🔍 [保存地块] ====== _savePlotData 被调用, plotName=$plotName ======');
final loc = AppLocalizations.of(context);
// 🔥 V2 适配:使用 SiteCubit 获取站点ID(从 v2 首页场站选择获取,有默认值)
final siteId = sl<SiteCubit>().state.selectedSite?.id;
if (siteId == null) {
debugPrint('❌ [保存地块] siteId为空,提前返回!');
_showPageToast(message: '请先在场站列表中选择一个场站!', type: ToastType.error);
return;
}
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() {
if (_currentRobotMode == RobotMode.robot) {
return _robotModeWgsPoints
.map((p) => {'lng': p.longitude, 'lat': p.latitude})
.toList();
}
return _markedPoints.map((point) {
final wgs84 = gcj02ToWgs84(point.latitude, point.longitude);
return {'lng': wgs84.longitude, 'lat': wgs84.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 为打点坐标
// 🔥 对齐 Web 端:弓字模式 path=API返回,自定义模式 path=打点
// 旧逻辑:自定义模式 path 为空数组
'path': _currentWorkMode == WorkMode.custom
? _markedPointsToWgs() // 自定义模式 path 为打点坐标
: typedPathList // 弓字模式 path 为 API 返回的路径
.map((point) => {'lng': point.longitude, 'lat': point.latitude})
.toList(),
'planModel': _currentWorkMode == WorkMode.bow ? 0 : 2, // 作业模式值
};
// 3. 构造 workRecord (包裹一层)
final Map<String, dynamic> workRecord = {
'workName': plotName,
'siteId': siteId, // 修改:使用 siteId 代替 userId
// 🔥 jsonData 必须是对象,不能是字符串(后端 PlanPath 类型要求)
'jsonData': savePath,
};
final String workRecordJson = jsonEncode(workRecord);
final http.MultipartRequest request = http.MultipartRequest(
'POST',
Uri.parse('http://1.95.137.212:8081/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发送...');
// 7. 发送请求
try {
// debugPrint('📤 [保存地块] ========== 请求参数 BEGIN ==========');
// debugPrint('📤 [保存地块] plotName: $plotName');
// debugPrint('📤 [保存地块] siteId: $siteId');
// debugPrint('📤 [保存地块] workMode: $_currentWorkMode');
// debugPrint('📤 [保存地块] planModel: ${savePath['planModel']}');
// debugPrint('📤 [保存地块] savePath.name: ${savePath['name']}');
// debugPrint('📤 [保存地块] path长度: ${(savePath['path'] as List?)?.length ?? 0}');
// debugPrint('📤 [保存地块] outer长度: ${(savePath['outer'] as List?)?.length ?? 0}');
// debugPrint('📤 [保存地块] 是否有图片文件: ${imgBase64 != null && imgBase64.isNotEmpty}');
// debugPrint('📤 [保存地块] 请求URL: http://1.95.137.212:8081/iot/workRecord/add');
// debugPrint('📤 [保存地块] workRecord(完整JSON): $workRecordJson');
// debugPrint('📤 [保存地块] savePath(原始对象): $savePath');
// debugPrint('📤 [保存地块] ========== 请求参数 END ==========');
_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);
}
// ========== 障碍物模式:完成当前障碍物绘制 ==========
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) {
final gcjPoints = _robotModeObsWgsPoints
.map((wgs) => wgs84ToGcj02(wgs.latitude, wgs.longitude))
.toList();
_obstacleHoles.add(gcjPoints);
_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() {
_traceManager.reset();
_mapController.dispose();
_pathPlanningSub?.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(' ├─ 地块名称: ${plot.plotName}');
debugPrint(' └─ 数据ID: ${plot.id}');
// 【优化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}');
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 = [];
for (var item in pathList) {
double? lat = safeToDouble(item['lat']);
double? lon = safeToDouble(item['lng'] ?? item['lon']);
if (lat != null && lon != null) {
newPathPoints.add(convertWGS84ToGCJ02(lat, lon));
}
}
for (var item in outerList) {
double? lng = safeToDouble(item['lng'] ?? item['lon']);
double? lat = safeToDouble(item['lat']);
if (lng != null && lat != null) {
newOuterPoints.add(convertWGS84ToGCJ02(lat, lng));
}
}
// 【优化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',
);
// 最后移动地图
List<LatLng> allPoints = [];
allPoints.addAll(gcjPathPoints);
allPoints.addAll(gcjOuterPoints);
if (allPoints.isNotEmpty) {
moveMapToPointsCenter(allPoints);
}
} catch (e) {
debugPrint('解析失败:$e');
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() {
return const LatLng(30.279651, 120.154871);
}
Widget _buildMap(
double lat,
double lng,
bool obfFlag,
int headingStatus,
String controlMode,
) {
_onDeviceData(lng, lat, obfFlag, headingStatus, controlMode);
final gcjPoint = wgs84ToGcj02(lat, lng);
//final initCenter = bd09ToGcj02(
// '120.81992468426961', // 经度
// '32.04532826114155',
//);
// 🔥 修复1:初始化坐标改为直接使用有效GCJ02坐标,避免BD09转换风险
final initCenter = _getDefaultValidCenter();
_currentWgsLatLng = LatLng(lat, lng);
_traceManager.upsert(_currentWgsLatLng as PlotPoint, TPAction.UPDATE);
tracePoint = _traceManager.getTracePoint();
gctracePoint = batchWgs84ToGcj02(tracePoint!);
// _logger.log("[当前轨迹模式][转换后gctracePoint]: ${gctracePoint!.length}");
// _logger.log("[当前轨迹模式]:${_traceManager.getMode()}"); // 这里必须加 ()
//_currentLatLng = gcjPoint; // ✅ 状态变量在setState内更新
if (_isValidLatLng(gcjPoint.latitude, gcjPoint.longitude)) {
_currentLatLng = gcjPoint;
} 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: [
/// 高德瓦片(GCJ-02)
TileLayer(
//urlTemplate:
// "https://webst02.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}&lang=zh_cn&size=1&scale=2&style=7&key=bbb1f0f20eed6bf679eddf2625630aba",
urlTemplate:
'https://webrd02.is.autonavi.com/appmaptile'
'?style=8&x={x}&y={y}&z={z}&lang=zh_cn&size=1&scale=1'
'&key=bbb1f0f20eed6bf679eddf2625630aba',
// 🔥 修复:适配高DPI(替代pixelRatio)
// tileSize: 512, // 高清瓦片尺寸(默认256,改为512适配scale=2)
tileProvider: NetworkTileProvider(
// 自定义瓦片请求头(可选,提升兼容性)
headers: {
'Referer': 'https://www.amap.com', // 高德要求的Referer
},
),
maxZoom: 18,
),
//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.fromARGB(255, 6, 187, 232),
strokeWidth: 3,
),
],
),
/// 当前定位 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(从 AppUserCubit 的 user)
final userCubit = sl<AppUserCubit>();
final orgId = userCubit.state.user?.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. 重置轨迹 + 切换到定位模式(关键:防止飞过去的路径被画出来)
debugPrint('🔄 [开始作业] 步骤1: 重置轨迹,切换到定位模式');
_traceManager.reset();
_traceManager.setMode(TPMode.LOCATION);
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;
}
// 🔥 步骤3: 任务确认成功,更新UI状态
debugPrint('🎯 [开始作业] 步骤3: 任务就绪, currentTaskId=${taskCubit.state.currentTaskId}');
setState(() {
isStopWork = false;
isStartWork = true;
_workStatus = WorkStatus.working;
});
context.read<DevicesCubit>().updateAppState(AppState.routePlanning);
// 🔥 步骤4: 刷新任务池(保护 taskId 不被覆盖)
debugPrint('🔄 [开始作业] 步骤4: 刷新任务池');
final _confirmedTaskId = taskCubit.state.currentTaskId;
await taskCubit.fetchAndFilterTask(deviceId);
if (_confirmedTaskId != null && taskCubit.state.currentTaskId == null) {
taskCubit.updateCurrentTaskId(_confirmedTaskId);
debugPrint('🛡️ [开始作业] taskId 被刷新覆盖,已还原: $_confirmedTaskId');
}
// 🔥 步骤5: 启动MQTT到达点监听
debugPrint('📡 [开始作业] 步骤5: 启动MQTT监听, deviceId: $deviceId, taskId: $_confirmedTaskId');
try {
await context.read<DevicesCubit>().startListeningMqttArrive(deviceId: deviceId, taskId: _confirmedTaskId!);
debugPrint('✅ [开始作业] MQTT到达点监听已启动');
} catch (e) {
debugPrint('⚠️ [开始作业] MQTT监听启动失败: $e (非致命,继续)');
}
// 🔥 步骤6: 持久化状态
debugPrint('💾 [开始作业] 步骤6: 持久化状态, 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();
}
void _stopWork() async {
_logger.log('[停止] 按下停止按钮');
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...');
await taskCubit.cancelTask(deviceId);
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: () {
debugPrint('❌ [工作面板] 关闭面板,重置状态');
setState(() {
_isWorkPanelOpen = false;
_selectedPlot = null;
gcjPathPoints = [];
gcjOuterPoints = [];
_workStatus = WorkStatus.idle;
_showHeadingWarn = false;
_showControlModeWarn = false;
isStartWork = false;
});
_saveDataToLocal();
_stopWork();
//if (isStopWork || !isStartWork) return;
},
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 activeTasks = taskState.activeTasks;
if (activeTasks.isEmpty) {
_showPageToast(
message: "没有可用的任务",
type: ToastType.warn,
);
return;
}
final selectedTask = await _showTaskSelectionDialog(
activeTasks,
);
if (selectedTask != null) {
// 用户选择了新任务,更新 cubit
sl<DeviceTaskCubit>().selectTask(selectedTask);
_showPageToast(
message: "已切换到任务 #${selectedTask.id}",
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.swap_horiz,
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 || headingStatus == 0)
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: () => _stopWork(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
foregroundColor: Colors.white,
shape: const CircleBorder(),
elevation: 2,
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
),
child: const Icon(
Icons.stop, // 停止图标(也可以用 Icons.close)
size: 24,
),
),
),
),
],
),
//SizedBox(
// width: double.infinity,
// height: 50,
// child: ElevatedButton(
// onPressed: state.isLoading || gcjPathPoints.isEmpty
// ? null
// : () {
// // 作业逻辑:使用加载的pathData
// debugPrint('开始作业:${_selectedPlot!.plotName},路径数据:$gcjPathPoints');
// },
// style: ElevatedButton.styleFrom(
// backgroundColor: const Color(0xFF00C853),
// foregroundColor: Colors.white,
// shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
// elevation: 2,
// disabledBackgroundColor: Colors.grey[300],
// disabledForegroundColor: Colors.grey[600],
// ),
// child: state.isLoading
// ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
// : const Text('开始作业', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
// ),
//),
],
),
),
);
},
);
}
Future<String?> _captureMapToJpgBase64({int quality = 80}) async {
try {
// 1. 第一步:截取 PNG (Flutter 唯一支持的格式)
RenderRepaintBoundary? boundary =
_mapRepaintKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) {
debugPrint('截图失败:渲染对象未找到');
return null;
}
// 🔥 注意:这里是 dart:ui 的 Image,需显式指定 ui.Image
ui.Image image = await boundary.toImage(pixelRatio: 2.0);
ByteData? byteData = await image.toByteData(
format: ui.ImageByteFormat.png,
);
if (byteData == null) {
debugPrint('截图失败:字节数据为空');
return null;
}
Uint8List pngBytes = byteData.buffer.asUint8List();
// 2. 第二步:使用 image 库解码
// 🚨 核心修复:变量类型必须显式写为 _image.Image?
_image.Image? decodedImage = _image.decodeImage(pngBytes);
if (decodedImage == null) {
debugPrint('转换失败:无法解码 PNG');
return null;
}
// 3. 第三步:编码为 JPG
List<int> jpgBytes = _image.encodeJpg(decodedImage, quality: quality);
Uint8List finalBytes = Uint8List.fromList(jpgBytes);
// 4. 第四步:转换为 Base64
String base64Str = base64Encode(finalBytes);
debugPrint('JPG Base64 长度:${base64Str.length} (体积更小)');
return base64Str;
} catch (e) {
debugPrint('生成 JPG Base64 异常:$e');
return null;
}
}
// 🔥 改动4:新增方法 - 将Bloc的workRecords转换为PlotData列表
List<PlotData> _convertWorkRecordsToPlotData(
List<Map<String, dynamic>> records,
) {
// debugPrint('📊 [_convertWorkRecordsToPlotData] 输入记录数: ${records.length}');
return records.map((record) {
final jd = record['jsonData'];
// debugPrint(' ├─ workName: ${record['workName']}, 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']}');
// }
// } 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();
}
// ========== 抽象:生成路径的核心函数(打点函数) ==========
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) {
LatLng firstPointGcj = _markedPoints.first;
firstPointWgs = gcj02ToWgs84(
firstPointGcj.latitude,
firstPointGcj.longitude,
);
} else if (_markedPoints.isNotEmpty) {
// 兜底:_robotModeWgsPoints 为空但_markedPoints有数据(新缓存key未写入等场景)
LatLng firstPointGcj = _markedPoints.first;
firstPointWgs = gcj02ToWgs84(
firstPointGcj.latitude,
firstPointGcj.longitude,
);
} 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 = _robotModeWgsPoints.map((latLng) {
return work_area_model.Position(
lat: latLng.latitude,
lon: latLng.longitude,
);
}).toList();
} else if (_markedPoints.isNotEmpty) {
// robot模式但_robotModeWgsPoints为空时,兜底用_markedPoints转换
outerPositions = _markedPoints.map((latLng) {
final wgs84Point = gcj02ToWgs84(latLng.latitude, latLng.longitude);
return work_area_model.Position(
lat: wgs84Point.latitude,
lon: wgs84Point.longitude,
);
}).toList();
}
gcjOuterPoints = outerPositions.map((point) {
double wgs84Lat = point.lat;
double wgs84Lon = point.lon;
final gcjPoint = wgs84ToGcj02(wgs84Lat, wgs84Lon);
return LatLng(gcjPoint.latitude, gcjPoint.longitude);
}).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) {
final wgs84Point = gcj02ToWgs84(
latLng.latitude,
latLng.longitude,
);
return work_area_model.Position(
lat: wgs84Point.latitude,
lon: wgs84Point.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})',
);
}
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);
}
}
} 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,
),
],
),
),
),
),
/// 核心:地图正中心固定定位标
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,
);
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 ?? [],
);
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,
);
final userId =
context
.read<AppUserCubit>()
.state
.user
?.userId ??
"";
await context
.read<DevicesCubit>()
.loadWorkRecords(userId);
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(),
],
),
),
);
},
);
}
}
// 转换单个WGS84坐标到GCJ02
LatLng convertWGS84ToGCJ02(double lat, double lon) {
return wgs84ToGcj02(lat, lon);
}
/// =======================================================
/// 坐标转换: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();
}
}