Files
flutterApp/lib/features/home/presentation/widgets/map/testmap_pages.dart
2026-02-26 10:44:56 +08:00

867 lines
32 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

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

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

import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.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/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 '../path_list_pages.dart';
class PlotData {
final String id; // 唯一ID,用于删除
final String plotName; // 地块名称
final String imageUrl; // 图片URL(本地/assets/网络都可)
PlotData({required this.id, required this.plotName, required this.imageUrl});
}
class MapPageEnterprise extends StatefulWidget {
const MapPageEnterprise({Key? key}) : super(key: key);
@override
State<MapPageEnterprise> createState() => _MapPageEnterpriseState();
}
class _MapPageEnterpriseState extends State<MapPageEnterprise> {
final MapController _mapController = MapController();
final TextEditingController _plotNameController = TextEditingController();
LatLng? _currentLatLng;
StreamSubscription<Position>? _positionSub;
bool _hasMovedOnce = false;
bool _isPanelOpen = false; // 控制面板显示/隐藏
bool _directionBoxOpen = false; //航线面板显示/隐藏
final bool _saveBoxOpen = true; //保存按钮显示/隐藏
bool _isListBoxOpen = false; //列表面板显示/隐藏
RobotMode _currentRobotMode = RobotMode.point; //打点模式
AreaMode _currentAreaMode = AreaMode.work; //作业区域还是障碍物区域
WorkMode? _currentWorkMode = WorkMode.bow; // 当前作业模式
// ========== 核心新增状态 ==========
final List<LatLng> _markedPoints = []; // 存储所有打点坐标
LatLng _mapCenter = const LatLng(39.9042, 116.4074);
final double _headingAngle = 0.0; // 当前机器航向角(单位:度)
//打点
double _workDistance = 0.0; // 作业行距(单位:米)
double _angle = 0.0; // 航线方向角(单位:度)
// =================================
//测试数据
final List<PlotData> _plotList = [
PlotData(
id: '1',
plotName: '北地块(一号田)',
imageUrl: '', // 本地图片
// 网络图片示例:imageUrl: 'https://xxx.com/plot1.jpg',
),
PlotData(id: '2', plotName: '东地块(二号田)', imageUrl: ''),
PlotData(id: '3', plotName: '西地块(三号田)', imageUrl: ''),
PlotData(id: '3', plotName: '西地块(三号田)', imageUrl: ''),
PlotData(id: '3', plotName: '西地块(三号田)', imageUrl: ''),
PlotData(id: '3', plotName: '西地块(三号田)', imageUrl: ''),
PlotData(id: '3', plotName: '西地块(三号田)', imageUrl: ''),
PlotData(id: '3', plotName: '西地块(三号田)', imageUrl: ''),
];
@override
void initState() {
super.initState();
_initLocationEnterprise();
// 监听地图移动事件,实时更新连线
_mapController.mapEventStream.listen((event) {
if (event is MapEventMove || event is MapEventMoveEnd) {
if (mounted && _mapController.camera != null) {
// 核心:直接获取原始经纬度,不做任何截断,保留最大精度
final originalCenter = _mapController.camera!.center;
// 显式创建新的 LatLng 对象,确保精度不丢失(避免引用传递导致的隐式截断)
final highPrecisionCenter = LatLng(
originalCenter.latitude, // 原始纬度(保留全部小数位)
originalCenter.longitude, // 原始经度(保留全部小数位)
);
setState(() {
_mapCenter = highPrecisionCenter;
});
// 可选:打印验证精度(保留15位小数)
debugPrint(
'地图中心更新(高精度):纬度=${_mapCenter.latitude.toStringAsFixed(15)}, 经度=${_mapCenter.longitude.toStringAsFixed(15)}',
);
}
}
});
}
void _showSavePlotDialog() {
// 清空上次的输入内容
_plotNameController.clear();
// 显示弹窗
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('保存地块'),
content: TextField(
controller: _plotNameController,
decoration: const InputDecoration(
hintText: '请输入地块名称(如:北地块、一号田)',
border: OutlineInputBorder(),
labelText: '地块名称',
),
),
actions: [
// 取消按钮
TextButton(
onPressed: () {
Navigator.of(context).pop(); // 关闭弹窗
},
child: const Text('取消'),
),
// 确认保存按钮
TextButton(
onPressed: () {
// 获取输入的地块名称并去除首尾空格
String plotName = _plotNameController.text.trim();
// 验证输入是否为空
if (plotName.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('地块名称不能为空!'),
backgroundColor: Colors.red,
),
);
return;
}
// 执行保存逻辑
_savePlotData(plotName);
// 关闭弹窗
Navigator.of(context).pop();
// 保存成功提示
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('地块「$plotName」保存成功!'),
backgroundColor: Colors.green,
),
);
},
child: const Text('保存'),
),
],
);
},
);
}
// 3. 保存地块数据的核心方法(这里写你的实际保存逻辑)
void _savePlotData(String plotName) {
// 示例:打印保存的信息(实际开发中替换为存数据库/本地存储/接口调用)
debugPrint('===== 保存地块数据 =====');
debugPrint('地块名称:$plotName');
debugPrint('打点数量:${_markedPoints.length}');
debugPrint('打点坐标:$_markedPoints');
debugPrint('作业模式:$_currentWorkMode');
// 这里可以添加实际的保存逻辑:
// 1. 存到本地数据库(如Hive/SQLite)
// 2. 调用API上传到服务器
// 3. 保存到SharedPreferences(简单数据)
}
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);
});
}
/// ===============================
/// 更新位置
/// ===============================
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)}',
);
}
@override
void dispose() {
_positionSub?.cancel();
super.dispose();
}
// 在State类中添加列表项构建方法
Widget _buildPlotListItem(PlotData plot) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[100]!),
),
child: Row(
children: [
// 1. 地块图片
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
plot.imageUrl,
// 网络图片替换为:Image.network(plot.imageUrl, ...)
width: 40,
height: 40,
fit: BoxFit.cover,
// 图片加载失败占位
errorBuilder: (context, error, stackTrace) {
return Container(
width: 40,
height: 40,
color: Colors.grey[200],
child: const Icon(Icons.image_outlined, color: Colors.grey),
);
},
),
),
const SizedBox(width: 12),
// 2. 地块名称(占满剩余空间)
Expanded(
child: Text(
plot.plotName,
style: const TextStyle(fontSize: 16, color: Colors.black87),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// 3. 删除按钮
IconButton(
onPressed: () {
// 弹出确认删除对话框
_showDeleteConfirmDialog(plot);
},
icon: const Icon(
Icons.delete_outline,
color: Colors.redAccent,
size: 20,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
);
}
// 实现删除确认弹窗
void _showDeleteConfirmDialog(PlotData plot) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('确认删除'),
content: Text('是否删除地块「${plot.plotName}」?删除后不可恢复。'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('取消'),
),
TextButton(
onPressed: () {
// 执行删除逻辑
setState(() {
_plotList.removeWhere((item) => item.id == plot.id);
});
// 关闭弹窗
Navigator.of(context).pop();
// 提示删除成功
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已删除地块「${plot.plotName}」'),
backgroundColor: Colors.green,
),
);
},
child: const Text('删除', style: TextStyle(color: Colors.red)),
),
],
);
},
);
}
@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: [
// 地图核心组件
FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter:
_currentLatLng ?? const LatLng(39.9042, 116.4074),
initialZoom: 15,
maxZoom: 18,
// 禁止地图点击事件(避免和中心标冲突)
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',
),
/// ========== 新增:中心标与历史打点的虚线连线 ==========
///
/// if (_markedPoints.length >= 2)
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(),
),
],
),
/// ========== 核心:地图正中心固定定位标 ==========
Positioned(
left: 0,
right: 0,
top: 0,
bottom: 0,
child: Center(
// 自定义十字标(中心精准对齐地图中心)
child: SizedBox(
width: 16, // 十字整体宽度
height: 16, // 十字整体高度
child: CustomPaint(
painter: CrosshairPainter(), // 自定义十字画笔
),
),
),
),
//保存按钮
if (_saveBoxOpen)
Positioned(
left: 16,
top: maxTop > 10 ? 11 : maxTop,
child: // 改用小尺寸按钮容器,替代默认大尺寸FloatingActionButton
Container(
width: 40, // 按钮宽度(默认FAB是56,改为40更小巧)
height: 40, // 按钮高度
decoration: BoxDecoration(
color: const Color(0xFF00C853), // 保留绿色主题
shape: BoxShape.circle, // 圆形
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 4, // 对应elevation:4的阴影效果
offset: const Offset(0, 2), // 阴影偏移
),
],
),
child: IconButton(
onPressed: () {
_showSavePlotDialog();
//// 点击保存按钮的逻辑
//// 1. 可以直接调用保存方法
//_saveCurrentPath();
//// 2. 也可以弹出输入框让用户输入路径名称(推荐)
//_showSavePathDialog();
},
icon: const Icon(
Icons.save,
color: Colors.white,
size: 18, // 图标尺寸缩小(原20→18)
),
padding: EdgeInsets.zero, // 移除IconButton默认内边距
constraints: const BoxConstraints(), // 解除IconButton尺寸限制
),
),
),
// 右侧悬浮菜单
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) {
setState(() {
_isListBoxOpen = isOpen;
});
},
onWorkModeSelected: (mode) {
_currentWorkMode = mode;
debugPrint('外部收到作业模式:$mode');
},
),
),
),
// 底部操作面板
if (_isPanelOpen)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: BottomOperationPanel(
initialRobotMode: RobotMode.point,
initialAreaMode: AreaMode.work,
onComplete: () {
debugPrint(
'操作完成回调:当前机器人模式=$_currentRobotMode,当前区域模式=$_currentAreaMode,当前作业模式:$_currentWorkMode,作业区域点:$_markedPoints,作业行距=$_workDistance,航线方向角=$_angle',
);
setState(() => _isPanelOpen = false);
},
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);
},
),
),
// 在build方法的Stack中添加抽屉组件
if (_isListBoxOpen)
// 半透明背景 + 底部抽屉
Positioned.fill(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// 1. 半透明遮罩(点击关闭抽屉)
Expanded(
child: GestureDetector(
onTap: () {
setState(() {
_isListBoxOpen = false;
});
},
child: Container(color: Colors.black.withOpacity(0.3)),
),
),
// 2. 底部抽屉主体
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];
return _buildPlotListItem(plot);
},
),
),
],
),
),
],
),
),
],
),
),
);
}
}
/// =======================================================
/// 坐标转换: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);
}
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;
}