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

2430 lines
101 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

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

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