1564 lines
64 KiB
Dart
1564 lines
64 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:math';
|
||
import 'dart:typed_data';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/rendering.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:flutter_map/flutter_map.dart';
|
||
import 'package:geolocator/geolocator.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:latlong2/latlong.dart';
|
||
import 'package:maibu_satabot_v2/components/confrim_dialog.dart';
|
||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||
import 'package:maibu_satabot_v2/core/di/injection.dart';
|
||
import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.dart';
|
||
import 'package:maibu_satabot_v2/core/network/protocol_decoder.dart';
|
||
import 'package:maibu_satabot_v2/features/devices/data/models/device_add_path_point_model.dart' as work_area_model;
|
||
import 'package:maibu_satabot_v2/features/devices/data/models/device_work_area_param_model.dart' as work_area_model;
|
||
import 'package:maibu_satabot_v2/features/devices/data/repositories/generate_path_repository_Impl.dart';
|
||
import 'package:maibu_satabot_v2/features/devices/domain/usecases/get_work_record_usecase.dart';
|
||
import 'package:maibu_satabot_v2/features/devices/domain/usecases/select_work_record_usecase.dart';
|
||
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/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/startpoint_area.dart';
|
||
import 'package:image/image.dart' as _image; // 注意命名空间冲突,使用 as img
|
||
import 'dart:ui' as ui;
|
||
|
||
import '../path_list_pages.dart';
|
||
|
||
// 定义轨迹点类型(经纬度)
|
||
typedef PlotPoint = LatLng;
|
||
|
||
// 保持 PlotData 类不变
|
||
class PlotData {
|
||
final String id;
|
||
final String plotName;
|
||
final String imageUrl; // 图片URL(本地/assets/网络都可)
|
||
final String? jsonData; // 原始数据的JSON字符串(可选,便于调试或后续使用)
|
||
|
||
PlotData({required this.id, required this.plotName, required this.imageUrl, this.jsonData});
|
||
}
|
||
|
||
class PlotDataPath {
|
||
final dynamic? jsonData; // 原始数据的JSON字符串(可选,便于调试或后续使用)
|
||
|
||
PlotDataPath({this.jsonData});
|
||
}
|
||
|
||
class MapPageEnterprise extends StatefulWidget {
|
||
const MapPageEnterprise({Key? key}) : super(key: key);
|
||
|
||
@override
|
||
State<MapPageEnterprise> createState() => _MapPageEnterpriseState();
|
||
}
|
||
|
||
class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||
final _dispatcher = sl<NetMessageDispatcher>();
|
||
late StreamSubscription<RawPacket> _sub;
|
||
bool _isRefreshing = false; // 新增:页面刷新状态标志
|
||
// 🔥 关键修复:添加截图全局Key
|
||
final GlobalKey _mapRepaintKey = GlobalKey();
|
||
|
||
final MapController _mapController = MapController();
|
||
// 初始化轨迹管理器(泛型指定为LatLng)
|
||
late final TracePoint<PlotPoint> _traceManager;
|
||
late String workMode = "弓字模式"; // 作业模式:默认值为"弓字模式"
|
||
|
||
LatLng? _currentLatLng;
|
||
StreamSubscription<Position>? _positionSub;
|
||
bool _hasMovedOnce = false;
|
||
bool _isPanelOpen = false; // 控制面板显示/隐藏
|
||
bool _directionBoxOpen = false; //航线面板显示/隐藏
|
||
final bool _saveBoxOpen = true; //保存按钮显示/隐藏
|
||
bool _isListBoxOpen = false; //列表面板显示/隐藏
|
||
// 新增:存储选中的地块(null表示未选中)
|
||
PlotData? _selectedPlot;
|
||
PlotDataPath? _selectedPlotPath; // 存储选中地块的路径数据(可选)
|
||
// 新增:控制底部作业面板显示
|
||
bool _isWorkPanelOpen = false;
|
||
|
||
RobotMode _currentRobotMode = RobotMode.point; //打点模式
|
||
AreaMode _currentAreaMode = AreaMode.work; //作业区域还是障碍物区域
|
||
WorkMode? _currentWorkMode = WorkMode.bow; // 当前作业模式
|
||
// 新增:存储转换后的path和outer坐标
|
||
List<LatLng> gcjPathPoints = [];
|
||
List<LatLng> gcjOuterPoints = [];
|
||
|
||
// ========== 核心新增状态 ==========
|
||
final List<LatLng> _markedPoints = []; // 存储所有打点坐标
|
||
LatLng _mapCenter = const LatLng(39.9042, 116.4074);
|
||
final double _headingAngle = 0.0; // 当前机器航向角(单位:度)
|
||
List<LatLng>? _currentTracePoints; //收到机器的点坐标
|
||
List typedPathList = <work_area_model.DeviceAddPathPointModel>[]; // 存储生成路径的坐标列表(已转换为LatLng)
|
||
|
||
//打点
|
||
double _workDistance = 0.0; // 作业行距(单位:米)
|
||
double _angle = 0.0; // 航线方向角(单位:度)
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_traceManager = TracePoint<PlotPoint>();
|
||
_initLocationEnterprise();
|
||
_sub = _dispatcher.onCommand(0x12).listen(_onDeviceData);
|
||
// 设置事件回调(更新UI)
|
||
_traceManager.onCurrentPointUpdated = (point) {
|
||
setState(() {
|
||
_currentTracePoints = _traceManager.getTracePoint();
|
||
});
|
||
};
|
||
_traceManager.onCompletePointAdded = (point) {
|
||
setState(() {
|
||
_currentTracePoints = _traceManager.getTracePoint();
|
||
});
|
||
};
|
||
|
||
// 示例:切换到导航模式
|
||
_traceManager.setMode(TPMode.LOCATION);
|
||
|
||
// 监听地图移动事件,实时更新连线
|
||
_mapController.mapEventStream.listen((event) {
|
||
if (event is MapEventMove || event is MapEventMoveEnd) {
|
||
if (mounted && _mapController.camera != null) {
|
||
final originalCenter = _mapController.camera!.center;
|
||
final highPrecisionCenter = LatLng(
|
||
originalCenter.latitude, // 原始纬度(保留全部小数位)
|
||
originalCenter.longitude, // 原始经度(保留全部小数位)
|
||
);
|
||
|
||
setState(() {
|
||
_mapCenter = highPrecisionCenter;
|
||
});
|
||
|
||
// 可选:打印验证精度(保留15位小数)
|
||
debugPrint('地图中心更新(高精度):纬度=${_mapCenter.latitude.toStringAsFixed(15)}, 经度=${_mapCenter.longitude.toStringAsFixed(15)}');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// ========== 新增:计算坐标列表的边界范围 ==========
|
||
LatLngBounds? calculateBounds(List<LatLng> points) {
|
||
if (points.isEmpty) return null;
|
||
|
||
// 使用静态方法 fromPoints 创建边界(所有latlong2版本都支持)
|
||
return LatLngBounds.fromPoints(points);
|
||
}
|
||
|
||
// ========== 新增:移动地图到坐标中心 ==========
|
||
// ========== 移动地图到坐标中心(无需修改) ==========
|
||
void moveMapToPointsCenter(List<LatLng> points) {
|
||
if (points.isEmpty || !mounted) return;
|
||
|
||
// 计算边界
|
||
final bounds = calculateBounds(points);
|
||
if (bounds == null) return;
|
||
|
||
// 移动地图到边界中心(自动适配缩放级别)
|
||
_mapController.fitBounds(
|
||
bounds,
|
||
options: FitBoundsOptions(
|
||
padding: const EdgeInsets.all(50), // 边缘留白(避免内容贴边)
|
||
maxZoom: 18, // 最大缩放级别(防止过度放大)
|
||
),
|
||
);
|
||
|
||
print('地图已移动到绘制内容中心,边界范围:$bounds');
|
||
}
|
||
|
||
// ========== 新增:安全转换任意类型为double ==========
|
||
double? safeToDouble(dynamic value) {
|
||
if (value == null) return null;
|
||
if (value is double) return value;
|
||
if (value is int) return value.toDouble();
|
||
if (value is String) {
|
||
return double.tryParse(value); // 字符串转数字(失败返回null)
|
||
}
|
||
return null;
|
||
}
|
||
|
||
void _onDeviceData(RawPacket packet) {
|
||
try {
|
||
debugPrint('>>> 收到0x12设备数据: ${packet}');
|
||
debugPrint('>>> 收到设备数据: ${utf8.decode(packet.payload)}');
|
||
final csv = utf8.decode(packet.payload);
|
||
final fields = csv.split(',');
|
||
// 至少需 16 个字段(索引 10=航向角, 13=卫星数, 14=定位质量)
|
||
if (fields.length >= 16) {
|
||
//setState(() {
|
||
// _voltage = fields[0]; // 电压
|
||
// _leftSpeed = fields[1]; // 左轮目标速度
|
||
// _rightSpeed = fields[2]; // 右轮目标速度
|
||
// _pitch = fields[11]; // 俯仰角
|
||
// _roll = fields[12]; // 翻滚角
|
||
// _latitude = fields[16]; // 纬度
|
||
// _longitude = fields[17]; // 经度
|
||
// _timeStamp = '${fields[16]} ${fields[17]}'; // 时间戳(合并字段)
|
||
// _knifeCuttingSpeed = fields[19]; // 割刀速度
|
||
// _controlMode = fields[20]; // 控制模式
|
||
// _workingArea = fields[21]; // 作业面积
|
||
// _battery = fields[22]; // 电量
|
||
// _obstacleFlag = fields[23]; // 障碍物标志
|
||
// _yaw = fields[10]; // 航向角
|
||
// _satelliteCnt = fields[13]; // 卫星数
|
||
// _qual = fields[14]; // 定位质量
|
||
//});
|
||
}
|
||
} catch (_) {
|
||
// 解析失败时忽略,避免崩溃
|
||
}
|
||
}
|
||
|
||
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);
|
||
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) {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('用户ID为空,无法保存'), backgroundColor: Colors.red));
|
||
return;
|
||
}
|
||
|
||
// 2. 构造 SavePath 数据 (严格对应你的 JS 结构)
|
||
final Map<String, dynamic> savePath = {
|
||
'img': imgBase64 ?? '', // 截图的 Base64 字符串
|
||
'name': plotName, // 地块名称
|
||
'path': jsonEncode(
|
||
typedPathList
|
||
.map(
|
||
(point) => {
|
||
// 路径点数组
|
||
'lon': point.longitude,
|
||
'lat': point.latitude,
|
||
},
|
||
)
|
||
.toList(),
|
||
),
|
||
'outer': jsonEncode(
|
||
_markedPoints
|
||
.map(
|
||
(point) => {
|
||
// 外边界点数组
|
||
'lon': point.longitude,
|
||
'lat': point.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 {
|
||
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) {
|
||
// 保存成功
|
||
setState(() {
|
||
_markedPoints.clear(); // 清空打点
|
||
gcjPathPoints.clear(); // 清空路径
|
||
});
|
||
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('地块「$plotName」保存成功!'), backgroundColor: Colors.green));
|
||
} else {
|
||
throw Exception('服务器错误: ${response.statusCode}, $responseBody');
|
||
}
|
||
} catch (e) {
|
||
debugPrint('保存失败: $e');
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存失败: ${e.toString()}'), backgroundColor: Colors.red));
|
||
}
|
||
}
|
||
|
||
List<LatLng> _getPolygonPoints() {
|
||
if (_markedPoints.isEmpty || _mapController.camera == null) return [];
|
||
|
||
List<LatLng> polygonPoints = List.from(_markedPoints);
|
||
polygonPoints.add(_mapCenter);
|
||
return polygonPoints;
|
||
}
|
||
|
||
void _undoLastPoint() {
|
||
if (_markedPoints.isNotEmpty) {
|
||
setState(() {
|
||
_markedPoints.removeLast(); // 删除最后一个打点
|
||
});
|
||
debugPrint('撤回成功,剩余打点数量:${_markedPoints.length}');
|
||
} else {
|
||
debugPrint('无打点可撤回');
|
||
// 可选:提示用户
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无打点可撤回')));
|
||
}
|
||
}
|
||
|
||
// ========== 核心:清空所有打点和轨迹 ==========
|
||
void _deleteAllPoints() {
|
||
if (_markedPoints.isNotEmpty) {
|
||
setState(() {
|
||
_markedPoints.clear(); // 清空所有打点
|
||
});
|
||
debugPrint('删除成功,已清空所有打点和轨迹');
|
||
// 可选:提示用户
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已清空所有打点和轨迹')));
|
||
} else {
|
||
debugPrint('无打点可删除');
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无打点可删除')));
|
||
}
|
||
}
|
||
|
||
/// ===============================
|
||
/// 企业级定位初始化(已修复坐标系)
|
||
/// ===============================
|
||
Future<void> _initLocationEnterprise() async {
|
||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||
if (!serviceEnabled) return;
|
||
|
||
LocationPermission permission = await Geolocator.checkPermission();
|
||
if (permission == LocationPermission.denied) {
|
||
permission = await Geolocator.requestPermission();
|
||
}
|
||
if (permission != LocationPermission.whileInUse && permission != LocationPermission.always) {
|
||
return;
|
||
}
|
||
|
||
// 1️⃣ 首次强制获取定位
|
||
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.bestForNavigation);
|
||
|
||
final gcj = wgs84ToGcj02(position.latitude, position.longitude);
|
||
_updateLocation(gcj, moveMap: true);
|
||
|
||
// 2️⃣ 实时监听(不再强制移动地图)
|
||
_positionSub = Geolocator.getPositionStream(locationSettings: const LocationSettings(accuracy: LocationAccuracy.bestForNavigation, distanceFilter: 2))
|
||
.listen((pos) {
|
||
final gcj = wgs84ToGcj02(pos.latitude, pos.longitude);
|
||
_updateLocation(gcj, moveMap: false);
|
||
});
|
||
}
|
||
|
||
// ========== 新增:页面刷新初始化方法 ==========
|
||
Future<void> _handleRefresh() async {
|
||
// 防止重复刷新
|
||
if (_isRefreshing) return;
|
||
|
||
setState(() {
|
||
_isRefreshing = true; // 标记开始刷新
|
||
});
|
||
|
||
try {
|
||
// 1. 取消旧的定位监听
|
||
await _positionSub?.cancel();
|
||
|
||
// 2. 重置所有页面状态(恢复初始值)
|
||
setState(() {
|
||
// 清空打点和轨迹数据
|
||
_markedPoints.clear();
|
||
gcjPathPoints.clear();
|
||
gcjOuterPoints.clear();
|
||
_currentTracePoints = null;
|
||
|
||
// 重置面板状态
|
||
_isPanelOpen = false;
|
||
_directionBoxOpen = false;
|
||
_isListBoxOpen = false;
|
||
_isWorkPanelOpen = false;
|
||
|
||
// 重置选中状态
|
||
_selectedPlot = null;
|
||
_selectedPlotPath = null;
|
||
|
||
// 重置作业模式
|
||
_currentRobotMode = RobotMode.point;
|
||
_currentAreaMode = AreaMode.work;
|
||
_currentWorkMode = WorkMode.bow;
|
||
|
||
// 重置参数
|
||
_workDistance = 0.0;
|
||
_angle = 0.0;
|
||
_mapCenter = const LatLng(39.9042, 116.4074);
|
||
_hasMovedOnce = false;
|
||
_currentLatLng = null;
|
||
});
|
||
|
||
// 3. 重新初始化定位
|
||
await _initLocationEnterprise();
|
||
|
||
// 4. 重置轨迹管理器
|
||
_traceManager.reset();
|
||
|
||
// 5. 重新加载作业记录(保持数据最新)
|
||
final userId = context.read<AppUserCubit>().state.user?.userId ?? "";
|
||
if (userId.isNotEmpty) {
|
||
await context.read<DevicesCubit>().loadWorkRecords(userId);
|
||
}
|
||
|
||
// 6. 重置地图视角到初始位置
|
||
_mapController.move(const LatLng(39.9042, 116.4074), 17);
|
||
|
||
// 刷新成功提示
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('页面已刷新完成'), backgroundColor: Colors.green, duration: Duration(seconds: 2)));
|
||
}
|
||
} catch (e) {
|
||
// 错误处理
|
||
debugPrint('刷新失败:$e');
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text('刷新失败:${e.toString().substring(0, 50)}'), backgroundColor: Colors.red, duration: Duration(seconds: 3)));
|
||
}
|
||
} finally {
|
||
// 标记刷新结束
|
||
if (mounted) {
|
||
setState(() {
|
||
_isRefreshing = false;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// ===============================
|
||
/// 更新位置
|
||
/// ===============================
|
||
void _updateLocation(LatLng latLng, {bool moveMap = false}) {
|
||
setState(() {
|
||
_currentLatLng = latLng;
|
||
});
|
||
|
||
if (moveMap && !_hasMovedOnce) {
|
||
_hasMovedOnce = true;
|
||
_mapController.move(latLng, 17);
|
||
}
|
||
}
|
||
|
||
/// 手动回到当前位置
|
||
void _moveToCurrentLocation() {
|
||
if (_currentLatLng == null) return;
|
||
_mapController.move(_currentLatLng!, 17);
|
||
}
|
||
|
||
// ========== 核心方法:打点逻辑 ==========
|
||
void _addMarkedPoint() {
|
||
setState(() {
|
||
_markedPoints.add(_mapCenter); // 在地图中心打点
|
||
});
|
||
debugPrint('$_markedPoints,新增打点:第${_markedPoints.length}个点,经纬度:${_mapCenter.latitude.toStringAsFixed(20)}, ${_mapCenter.longitude.toStringAsFixed(20)}');
|
||
}
|
||
|
||
// ========== 新增:返回上一级页面的方法 ==========
|
||
void _navigateBack() {
|
||
// 关闭当前页面,返回上一级
|
||
Navigator.of(context).pop();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_positionSub?.cancel();
|
||
_traceManager.reset();
|
||
_mapController.dispose();
|
||
_sub.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
// 模拟添加完成点
|
||
void _addCompletePoint() {
|
||
final newPoint = LatLng(39.9042 + (DateTime.now().millisecond / 10000), 116.4074 + (DateTime.now().millisecond / 10000));
|
||
_traceManager.upsert(newPoint, TPAction.ADD);
|
||
}
|
||
|
||
// 模拟更新当前点
|
||
void _updateCurrentPoint() {
|
||
final newPoint = LatLng(39.9042 + (DateTime.now().millisecond / 10000), 116.4074 + (DateTime.now().millisecond / 10000));
|
||
_traceManager.upsert(newPoint);
|
||
}
|
||
|
||
// 🔥 改动2:修改 _buildPlotListItem 方法,增加删除时调用Bloc的逻辑
|
||
Widget _buildPlotListItem(PlotData plot, Function(PlotData) onDelete) {
|
||
return GestureDetector(
|
||
// 核心:点击列表项触发选中逻辑
|
||
onTap: () async {
|
||
// 1. 清空之前的轨迹数据
|
||
setState(() {
|
||
gcjPathPoints = [];
|
||
gcjOuterPoints = [];
|
||
_currentTracePoints = null;
|
||
});
|
||
|
||
await context.read<DevicesCubit>().loadSelectedPath(plot.plotName);
|
||
final cubitState = context.read<DevicesCubit>().state;
|
||
final _loadedPlot = cubitState.pathData;
|
||
print('加载地块「${plot.plotName}」的路径数据: $_loadedPlot');
|
||
|
||
// 2. 立即解析并更新状态(在事件回调中,不在 build 中)
|
||
if (_loadedPlot is List) {
|
||
final List<dynamic> rawList = _loadedPlot as List<dynamic>;
|
||
final pathRecords = rawList.where((item) => item is Map<String, dynamic>).cast<Map<String, dynamic>>().toList();
|
||
|
||
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 ? "弓字模式" : "自定义模式";
|
||
if (parsedJson is Map<String, dynamic>) {
|
||
final Map<String, dynamic> nestedJson = parsedJson;
|
||
final dynamic rawPath = nestedJson['path'];
|
||
final dynamic rawOuter = nestedJson['outer'];
|
||
List<dynamic> pathList = rawPath is List ? rawPath : [];
|
||
List<dynamic> outerList = rawOuter is List ? rawOuter : [];
|
||
|
||
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;
|
||
_selectedPlot = plot;
|
||
_isListBoxOpen = false;
|
||
_isWorkPanelOpen = true;
|
||
});
|
||
|
||
// 移动地图到中心
|
||
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;
|
||
});
|
||
}
|
||
} else {
|
||
setState(() {
|
||
gcjPathPoints = [];
|
||
gcjOuterPoints = [];
|
||
_selectedPlot = plot;
|
||
_isListBoxOpen = false;
|
||
_isWorkPanelOpen = true;
|
||
});
|
||
}
|
||
} 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(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showDeleteConfirmDialog(PlotData plot, Function(PlotData) onDelete) {
|
||
ConfirmDialog.show(
|
||
context: context,
|
||
title: '确认删除',
|
||
content: '是否删除地块「${plot.plotName}」?删除后不可恢复。',
|
||
confirmText: '删除',
|
||
confirmTextColor: Colors.red,
|
||
onConfirm: () {
|
||
onDelete(plot);
|
||
if (context.mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已删除地块「${plot.plotName}」')));
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
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. 开始作业按钮(加载中禁用)
|
||
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();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final screenHeight = MediaQuery.of(context).size.height;
|
||
final menuHeight = 16 * 6; // 假设 VerticalFloatMenu 有 6 个选项,每个高度为 56
|
||
final maxTop = screenHeight - menuHeight;
|
||
|
||
return Scaffold(
|
||
body: SafeArea(
|
||
child: Stack(
|
||
children: [
|
||
RepaintBoundary(
|
||
key: _mapRepaintKey,
|
||
child: FlutterMap(
|
||
mapController: _mapController,
|
||
options: MapOptions(
|
||
initialCenter: _currentLatLng ?? const LatLng(39.9042, 116.4074),
|
||
initialZoom: 20,
|
||
maxZoom: 22,
|
||
// 禁止地图点击事件(避免和中心标冲突)
|
||
onTap: (_, __) {}, // 空实现,禁用地图点击响应
|
||
),
|
||
children: [
|
||
/// 高德瓦片(GCJ-02)
|
||
TileLayer(
|
||
urlTemplate:
|
||
'https://webrd02.is.autonavi.com/appmaptile'
|
||
'?style=8&x={x}&y={y}&z={z}&lang=zh_cn&size=1&scale=1'
|
||
'&key=bbb1f0f20eed6bf679eddf2625630aba',
|
||
),
|
||
// 绘制path折线(Line模式)
|
||
if (gcjPathPoints.isNotEmpty)
|
||
PolylineLayer(
|
||
polylines: [
|
||
Polyline(
|
||
points: gcjPathPoints, // 转换后的GCJ02坐标
|
||
color: const Color.fromARGB(255, 223, 228, 116), // 折线颜色(可自定义)
|
||
strokeWidth: 1.0, // 折线宽度
|
||
isDotted: false, // 非虚线(Line模式)
|
||
borderColor: Colors.white, // 可选:添加白色描边,提升辨识度
|
||
borderStrokeWidth: 0.5,
|
||
),
|
||
],
|
||
),
|
||
|
||
// 绘制outer边框(Polygon模式)
|
||
if (gcjOuterPoints.isNotEmpty)
|
||
PolygonLayer(
|
||
polygons: [
|
||
Polygon(
|
||
points: gcjOuterPoints, // 转换后的GCJ02坐标
|
||
color: Colors.green.withOpacity(0.1), // 内部填充色(透明)
|
||
borderColor: Colors.green, // 边框颜色
|
||
borderStrokeWidth: 1.0, // 边框宽度
|
||
isFilled: true, // 开启填充(即使透明,也需要开启才能显示边框)
|
||
),
|
||
],
|
||
),
|
||
|
||
/// 中心标与历史打点的虚线连线
|
||
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)
|
||
PolylineLayer(
|
||
polylines: [
|
||
Polyline(points: [_mapCenter, _markedPoints.last], color: Colors.blue.withOpacity(0.5), strokeWidth: 1.5),
|
||
],
|
||
),
|
||
|
||
/// 当前定位 Marker
|
||
if (_currentLatLng != null)
|
||
MarkerLayer(
|
||
markers: [
|
||
Marker(
|
||
point: _currentLatLng!,
|
||
width: 40,
|
||
height: 40,
|
||
child: CustomPaint(
|
||
size: const Size(40, 40),
|
||
painter: HeadingMarkerPainter(headingAngle: _headingAngle),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (_currentWorkMode == WorkMode.bow && _markedPoints.isNotEmpty)
|
||
PolygonLayer(
|
||
polygons: [
|
||
Polygon(
|
||
points: _getPolygonPoints(),
|
||
color: Colors.green.withOpacity(0.2),
|
||
borderColor: Colors.green.withOpacity(0.5),
|
||
borderStrokeWidth: 1,
|
||
isFilled: true,
|
||
),
|
||
],
|
||
),
|
||
|
||
/// 历史打点的绿色标记
|
||
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 (_isRefreshing)
|
||
Positioned.fill(
|
||
child: Container(
|
||
color: Colors.black.withOpacity(0.3),
|
||
child: const Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [CircularProgressIndicator(color: Colors.white, strokeWidth: 3)],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 悬浮返回按钮(核心修改)
|
||
Positioned(
|
||
top: 10,
|
||
left: 10,
|
||
child: GestureDetector(
|
||
// 自定义点击事件
|
||
onTap: _navigateBack,
|
||
// 自定义点击反馈(替代 IconButton 的高亮/水波纹)
|
||
child: Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withOpacity(0.8),
|
||
borderRadius: BorderRadius.circular(20),
|
||
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 3, offset: const Offset(0, 2))],
|
||
),
|
||
// 核心:用 Stack 实现点击高亮层 + 图标层
|
||
child: Stack(
|
||
alignment: Alignment.center, // 所有子组件居中
|
||
children: [
|
||
// 1. 点击高亮层(默认隐藏,点击时显示)
|
||
Positioned.fill(
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.transparent, // 默认透明
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
),
|
||
),
|
||
// 2. 图标层(绝对居中)
|
||
const Icon(Icons.arrow_back_ios, color: Colors.black87, size: 20),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
/// 核心:地图正中心固定定位标
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
child: Center(
|
||
// 自定义十字标(中心精准对齐地图中心)
|
||
child: SizedBox(
|
||
width: 16, // 十字整体宽度
|
||
height: 16, // 十字整体高度
|
||
child: CustomPaint(
|
||
painter: CrosshairPainter(), // 自定义十字画笔
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
//保存按钮
|
||
if (_saveBoxOpen)
|
||
Positioned(
|
||
right: 80,
|
||
top: maxTop > 10 ? 11 : maxTop,
|
||
child: Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF00C853),
|
||
shape: BoxShape.circle,
|
||
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4, offset: const Offset(0, 2))],
|
||
),
|
||
child: IconButton(
|
||
onPressed: () {
|
||
_showSavePlotDialog();
|
||
},
|
||
icon: const Icon(Icons.save, color: Colors.white, size: 18),
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 右侧悬浮菜单
|
||
Positioned(
|
||
height: 336,
|
||
right: 16,
|
||
top: maxTop > 10 ? 11 : maxTop,
|
||
child: FloatingActionButton(
|
||
onPressed: _moveToCurrentLocation,
|
||
child: new VerticalFloatMenu(
|
||
onEditTap: (bool isOpen) {
|
||
setState(() {
|
||
_isPanelOpen = isOpen;
|
||
});
|
||
},
|
||
onListBox: (bool isOpen) {
|
||
// 🔥 改动5:加载作业记录(原有逻辑保留)
|
||
final userId = context.read<AppUserCubit>().state.user?.userId ?? "";
|
||
print('加载作业记录,当前用户ID:$userId');
|
||
context.read<DevicesCubit>().loadWorkRecords(userId);
|
||
|
||
setState(() {
|
||
_isListBoxOpen = isOpen;
|
||
_isWorkPanelOpen = false;
|
||
});
|
||
},
|
||
onWorkModeSelected: (mode) {
|
||
_currentWorkMode = mode;
|
||
debugPrint('外部收到作业模式:$mode');
|
||
},
|
||
onRefreshTap: _handleRefresh, // 推荐用局部刷新
|
||
),
|
||
),
|
||
),
|
||
|
||
// 底部操作面板
|
||
if (_isPanelOpen)
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: BottomOperationPanel(
|
||
initialRobotMode: RobotMode.point,
|
||
initialAreaMode: AreaMode.work,
|
||
onComplete: () async {
|
||
debugPrint(
|
||
'操作完成回调:当前机器人模式=$_currentRobotMode,当前区域模式=$_currentAreaMode,当前作业模式:$_currentWorkMode,作业区域点:$_markedPoints,作业行距=$_workDistance,航线方向角=$_angle',
|
||
);
|
||
|
||
// 1. 校验必要条件(至少需要3个打点才能形成闭合区域)
|
||
if (_markedPoints.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请先添加作业区域打点!'), backgroundColor: Colors.red));
|
||
return;
|
||
}
|
||
if (_markedPoints.length < 3) {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('作业区域至少需要3个打点才能生成路径!'), backgroundColor: Colors.red));
|
||
return;
|
||
}
|
||
if (_currentWorkMode == null) {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请先选择作业模式!'), backgroundColor: Colors.red));
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 2. 转换页面数据为 generatePath 所需的参数格式
|
||
// 2.1 参考点(取第一个打点,转换为 WGS84)
|
||
final firstPoint = _markedPoints.first;
|
||
final wgs84FirstPoint = gcj02ToWgs84(firstPoint.latitude, firstPoint.longitude);
|
||
final referencePoint = work_area_model.ReferencePoint(lat: wgs84FirstPoint.latitude, lon: wgs84FirstPoint.longitude);
|
||
|
||
// 2.2 航向角(转换为整型,-1表示最优航向则取-1)
|
||
final heading = _angle == -1 ? -1 : _angle.toInt();
|
||
|
||
// 2.3 外边界(转换为 OuterBoundary 格式,所有点转 WGS84)
|
||
final outerBoundary = work_area_model.OuterBoundary(
|
||
// 将 LatLng 打点转换为 Position 列表(先转 WGS84)
|
||
position: _markedPoints.map((latLng) {
|
||
// 关键:每个点都转换为 WGS84 坐标系
|
||
final wgs84Point = gcj02ToWgs84(latLng.latitude, latLng.longitude);
|
||
return work_area_model.Position(lat: wgs84Point.latitude, lon: wgs84Point.longitude);
|
||
}).toList(),
|
||
sideWidth: _workDistance,
|
||
);
|
||
|
||
final holes = <work_area_model.HoleBoundary>[];
|
||
|
||
final workType = _currentWorkMode == WorkMode.bow ? 0 : 2; // 注意:这里弓字模式是0,自定义是2(确认和后端一致)
|
||
|
||
// 3. 调用 Cubit 的 generatePath 方法生成路径
|
||
await context.read<DevicesCubit>().generatePath(
|
||
reference: referencePoint,
|
||
heading: heading,
|
||
outer: outerBoundary,
|
||
holes: holes,
|
||
workType: workType,
|
||
);
|
||
|
||
// 4. 获取生成结果并处理
|
||
final cubitState = context.read<DevicesCubit>().state;
|
||
print('${cubitState.generatedPath},路径生成结果');
|
||
if (cubitState.errorMessage != null) {
|
||
// 生成失败
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('路径生成失败:${cubitState.errorMessage}'), backgroundColor: Colors.red));
|
||
} else if (cubitState.generatedPath != null) {
|
||
// 生成成功
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('路径生成成功!'), backgroundColor: Colors.green));
|
||
debugPrint('生成的路径数据:${cubitState.generatedPath}');
|
||
|
||
// 可选:将生成的路径转换为地图可显示的坐标(WGS84 → GCJ02)
|
||
if (cubitState.generatedPath is List) {
|
||
setState(() {
|
||
// 先将 List<dynamic> 转换为 List<DeviceAddPathPointModel>
|
||
final pathList = cubitState.generatedPath as List;
|
||
typedPathList = pathList.whereType<work_area_model.DeviceAddPathPointModel>().toList();
|
||
|
||
// 然后使用 .lat 和 .lon 属性访问
|
||
gcjPathPoints = typedPathList.map((point) {
|
||
double wgs84Lat = point.latitude;
|
||
double wgs84Lon = point.longitude;
|
||
final gcjPoint = wgs84ToGcj02(wgs84Lat, wgs84Lon);
|
||
return LatLng(gcjPoint.latitude, gcjPoint.longitude);
|
||
}).toList();
|
||
|
||
gcjOuterPoints = List.from(_markedPoints); // 生成新列表,复制所有元素 // ========== 核心修改:清空临时打点列表 ==========
|
||
_markedPoints.clear(); // 清空后,临时划线会自动消失
|
||
});
|
||
moveMapToPointsCenter(gcjPathPoints);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// 异常处理
|
||
debugPrint('路径生成异常:$e');
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text('路径生成异常:${e.toString().substring(0, 50)}'), backgroundColor: Colors.red));
|
||
}
|
||
|
||
// 5. 关闭操作面板
|
||
setState(() {
|
||
_isPanelOpen = false; // 关闭操作面板
|
||
_currentWorkMode = null; // 清空作业模式,清除弓字模式的绿色半透明区域
|
||
});
|
||
},
|
||
onUndoTap: _undoLastPoint,
|
||
onDeleteTap: _deleteAllPoints,
|
||
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)}米');
|
||
},
|
||
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,
|
||
onConfirm: (result) {
|
||
debugPrint('最优航向:${result['optimalHeading']},角度:${result['direction']}');
|
||
_angle = result['optimalHeading'] == true ? -1 : result['direction'];
|
||
debugPrint('外部处理航线方向设置,当前角度:${_angle}');
|
||
setState(() => _directionBoxOpen = 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(),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// 转换单个WGS84坐标到GCJ02
|
||
LatLng convertWGS84ToGCJ02(double lat, double lon) {
|
||
return wgs84ToGcj02(lat, lon);
|
||
}
|
||
|
||
/// =======================================================
|
||
/// 坐标转换:WGS84 -> GCJ02(国内高德/腾讯通用)
|
||
/// =======================================================
|
||
const double _pi = 3.14159265358979324;
|
||
const double _a = 6378245.0;
|
||
const double _ee = 0.00669342162296594323;
|
||
|
||
LatLng wgs84ToGcj02(double lat, double lon) {
|
||
if (_outOfChina(lat, lon)) {
|
||
return LatLng(lat, lon);
|
||
}
|
||
double dLat = _transformLat(lon - 105.0, lat - 35.0);
|
||
double dLon = _transformLon(lon - 105.0, lat - 35.0);
|
||
double radLat = lat / 180.0 * _pi;
|
||
double magic = sin(radLat);
|
||
magic = 1 - _ee * magic * magic;
|
||
double sqrtMagic = sqrt(magic);
|
||
dLat = (dLat * 180.0) / ((_a * (1 - _ee)) / (magic * sqrtMagic) * _pi);
|
||
dLon = (dLon * 180.0) / (_a / sqrtMagic * cos(radLat) * _pi);
|
||
double mgLat = lat + dLat;
|
||
double mgLon = lon + dLon;
|
||
return LatLng(mgLat, mgLon);
|
||
}
|
||
|
||
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;
|
||
}
|