优化和路径规划新适配
This commit is contained in:
@@ -34,4 +34,14 @@ abstract class DroneStationDataSource {
|
||||
VideoQualityType qualityType = VideoQualityType.adaptive,
|
||||
int videoExpire = 720000000,
|
||||
});
|
||||
|
||||
/// 暂停飞行任务(通过 flightTaskCommand 接口)
|
||||
Future<Map<String, dynamic>> pauseFlightTask({
|
||||
required String deviceSn,
|
||||
});
|
||||
|
||||
/// 返航(通过 flightTaskCommand 接口)
|
||||
Future<Map<String, dynamic>> returnHome({
|
||||
required String deviceSn,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -330,4 +330,88 @@ class DroneStationDataSourceImpl implements DroneStationDataSource {
|
||||
return 'high';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> pauseFlightTask({
|
||||
required String deviceSn,
|
||||
}) async {
|
||||
final response = await dio.post(
|
||||
HttpApiConsts.flightTaskCommand,
|
||||
data: {
|
||||
'command': 'flighttask_pause',
|
||||
'deviceSn': deviceSn,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
// 🔥 重要:接口返回的是 text/plain,需要手动解析 JSON
|
||||
dynamic responseData;
|
||||
if (response.data is String) {
|
||||
try {
|
||||
responseData = jsonDecode(response.data as String);
|
||||
} catch (e) {
|
||||
throw Exception('响应数据解析失败: $e');
|
||||
}
|
||||
} else {
|
||||
responseData = response.data;
|
||||
}
|
||||
|
||||
// 确保 responseData 是 Map
|
||||
if (responseData is! Map<String, dynamic>) {
|
||||
throw Exception('响应数据格式错误');
|
||||
}
|
||||
|
||||
// 🔥 直接返回接口返回的 message,不自己拟定
|
||||
if (responseData['code'] != 200) {
|
||||
final message = responseData['message'] ?? '操作失败';
|
||||
throw Exception(message);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> returnHome({
|
||||
required String deviceSn,
|
||||
}) async {
|
||||
final response = await dio.post(
|
||||
HttpApiConsts.flightTaskCommand,
|
||||
data: {
|
||||
'command': 'return_home',
|
||||
'deviceSn': deviceSn,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
// 🔥 重要:接口返回的是 text/plain,需要手动解析 JSON
|
||||
dynamic responseData;
|
||||
if (response.data is String) {
|
||||
try {
|
||||
responseData = jsonDecode(response.data as String);
|
||||
} catch (e) {
|
||||
throw Exception('响应数据解析失败: $e');
|
||||
}
|
||||
} else {
|
||||
responseData = response.data;
|
||||
}
|
||||
|
||||
// 确保 responseData 是 Map
|
||||
if (responseData is! Map<String, dynamic>) {
|
||||
throw Exception('响应数据格式错误');
|
||||
}
|
||||
|
||||
// 🔥 直接返回接口返回的 message,不自己拟定
|
||||
if (responseData['code'] != 200) {
|
||||
final message = responseData['message'] ?? '操作失败';
|
||||
throw Exception(message);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,4 +120,28 @@ class DroneStationRepositoryImpl implements DroneStationRepository {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, Map<String, dynamic>>> pauseFlightTask({
|
||||
required String deviceSn,
|
||||
}) async {
|
||||
try {
|
||||
final result = await dataSource.pauseFlightTask(deviceSn: deviceSn);
|
||||
return Right(result);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, Map<String, dynamic>>> returnHome({
|
||||
required String deviceSn,
|
||||
}) async {
|
||||
try {
|
||||
final result = await dataSource.returnHome(deviceSn: deviceSn);
|
||||
return Right(result);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,4 +37,14 @@ abstract class DroneStationRepository {
|
||||
VideoQualityType qualityType,
|
||||
int videoExpire,
|
||||
});
|
||||
|
||||
/// 暂停飞行任务(通过 flightTaskCommand 接口)
|
||||
Future<Either<Failure, Map<String, dynamic>>> pauseFlightTask({
|
||||
required String deviceSn,
|
||||
});
|
||||
|
||||
/// 返航(通过 flightTaskCommand 接口)
|
||||
Future<Either<Failure, Map<String, dynamic>>> returnHome({
|
||||
required String deviceSn,
|
||||
});
|
||||
}
|
||||
@@ -55,7 +55,8 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
|
||||
DroneTaskInfo? _droneTaskInfo;
|
||||
DroneStationBloc? _droneStationBloc;
|
||||
DroneOsdDataSource? _droneOsdDataSource;
|
||||
StreamSubscription<DroneOsdEntity>? _osdSubscription;
|
||||
StreamSubscription<DroneOsdEntity>? _osdSubscription; // 无人机 OSD
|
||||
StreamSubscription<DroneOsdEntity>? _stationOsdSubscription; // 🔥 机场 OSD
|
||||
Timer? _simulationTimer;
|
||||
List<LatLng> _trajectoryPoints = [];
|
||||
LatLng? _currentPosition;
|
||||
@@ -97,6 +98,7 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
|
||||
_bloc.close();
|
||||
_droneStationBloc?.close();
|
||||
_osdSubscription?.cancel();
|
||||
_stationOsdSubscription?.cancel(); // 🔥 取消机场 OSD 订阅
|
||||
_droneOsdDataSource?.dispose();
|
||||
_mapController?.dispose();
|
||||
_destroyRtcEngine();
|
||||
@@ -160,6 +162,8 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
|
||||
_simulationTimer = null;
|
||||
_osdSubscription?.cancel();
|
||||
_osdSubscription = null;
|
||||
_stationOsdSubscription?.cancel(); // 🔥 取消机场 OSD 订阅
|
||||
_stationOsdSubscription = null;
|
||||
_droneOsdDataSource?.dispose();
|
||||
_droneOsdDataSource = null;
|
||||
_droneStationBloc?.close();
|
||||
@@ -201,22 +205,12 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
|
||||
|
||||
// 加载视频流
|
||||
_loadVideoStream();
|
||||
|
||||
// 🔥 启动模拟轨迹用于测试(可以选择不同模式)
|
||||
// 模式选项: circle(圆形)、polygon(六边形)、bow(弓字形)、rectangle(矩形)
|
||||
// 等天气好了,注释掉下面这行即可使用真实 MQTT 数据
|
||||
debugPrint('🚀 [FloatBarWidget] 准备启动模拟轨迹...');
|
||||
_startSimulationTrajectory(mode: SimulationMode.circle, pointCount: 30);
|
||||
} else {
|
||||
debugPrint('⚠️ [FloatBarWidget] 无人机离线,不启动 OSD 监听和视频流');
|
||||
debugPrint('🚀 [FloatBarWidget] 但仍然启动模拟轨迹用于测试...');
|
||||
|
||||
setState(() {
|
||||
_videoError = '无人机离线,无视频信号';
|
||||
});
|
||||
|
||||
// 离线时也启动模拟轨迹,方便测试
|
||||
_startSimulationTrajectory(mode: SimulationMode.circle, pointCount: 30);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,25 +225,31 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
|
||||
gatewaySn: _droneTaskInfo!.gatewaySn,
|
||||
);
|
||||
|
||||
// 🔥 监听无人机 OSD(轨迹、位置等)
|
||||
_osdSubscription = _droneOsdDataSource!.droneOsdStream.listen(
|
||||
(osd) {
|
||||
if (!mounted) return;
|
||||
_handleOsdUpdate(osd);
|
||||
_handleDroneOsdUpdate(osd);
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('❌ [FloatBarWidget] OSD 监听错误: $error');
|
||||
debugPrint('❌ [FloatBarWidget] 无人机 OSD 监听错误: $error');
|
||||
},
|
||||
);
|
||||
|
||||
// 🔥 新增:监听机场 OSD(环境温度、风速等)
|
||||
_stationOsdSubscription = _droneOsdDataSource!.stationOsdStream.listen(
|
||||
(osd) {
|
||||
if (!mounted) return;
|
||||
_handleStationOsdUpdate(osd);
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('❌ [FloatBarWidget] 机场 OSD 监听错误: $error');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 处理 OSD 数据更新
|
||||
void _handleOsdUpdate(DroneOsdEntity osd) {
|
||||
// ⚠️ 调试期间:忽略 MQTT 数据,只看模拟轨迹
|
||||
if (_simulationTimer != null && _simulationTimer!.isActive) {
|
||||
debugPrint('⚠️ [FloatBarWidget] 模拟轨迹运行中,忽略 MQTT 数据');
|
||||
return;
|
||||
}
|
||||
|
||||
/// 处理无人机 OSD 数据更新(轨迹、位置等)
|
||||
void _handleDroneOsdUpdate(DroneOsdEntity osd) {
|
||||
final rawData = osd.rawData;
|
||||
|
||||
debugPrint('📍 [FloatBarWidget] 收到 OSD 数据: ${rawData.keys.join(', ')}');
|
||||
@@ -307,6 +307,20 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 处理机场 OSD 数据更新(环境温度、风速等)
|
||||
void _handleStationOsdUpdate(DroneOsdEntity osd) {
|
||||
final rawData = osd.rawData;
|
||||
|
||||
debugPrint('\n========== 📥 [FloatBarWidget] 机场OSD 完整原始数据 ==========');
|
||||
debugPrint('$rawData');
|
||||
debugPrint('===========================================\n');
|
||||
|
||||
// TODO: 在这里解析机场的环境温度和风速数据
|
||||
// 例如:
|
||||
// final environmentTemp = rawData['environment_temperature'];
|
||||
// final windSpeed = rawData['wind_speed'];
|
||||
}
|
||||
|
||||
/// 计算两点之间的距离(米)- Haversine 公式
|
||||
double _calculateDistance(double lat1, double lon1, double lat2, double lon2) {
|
||||
const double earthRadius = 6371000; // 地球半径(米)
|
||||
|
||||
@@ -204,22 +204,53 @@ class _CreateTaskPageState extends State<CreateTaskPage> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Color(0xFFE5E6EB)),
|
||||
minimumSize: const Size(80, 36),
|
||||
// 取消按钮
|
||||
SizedBox(
|
||||
width: 100,
|
||||
height: 44,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Color(0xFFE5E6EB)),
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'取消',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF4E5969),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _onCreateTask,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
minimumSize: const Size(100, 36),
|
||||
// 确认创建按钮
|
||||
SizedBox(
|
||||
width: 140,
|
||||
height: 44,
|
||||
child: ElevatedButton(
|
||||
onPressed: _onCreateTask,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'确认创建',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('确认创建'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -9,6 +9,8 @@ import '../../domain/entities/flight_task_entity.dart';
|
||||
import '../../domain/entities/flight_task_detail_entity.dart';
|
||||
import '../../domain/entities/drone_station_entity.dart';
|
||||
import '../../domain/usecases/update_flight_task_status_usecase.dart';
|
||||
import '../../domain/usecases/pause_flight_task_usecase.dart';
|
||||
import '../../domain/usecases/return_home_usecase.dart';
|
||||
import '../bloc/drone_station_bloc.dart';
|
||||
import '../float_bar/view/float_bar_widget.dart';
|
||||
|
||||
@@ -28,6 +30,10 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
FlightTaskDetailEntity? _detailTask; // 详情数据
|
||||
bool _isLoading = false;
|
||||
final Dio _dio = Dio();
|
||||
|
||||
// 🔥 任务状态管理
|
||||
bool _isPaused = false; // 是否已暂停(用于切换暂停/恢复按钮)
|
||||
bool _isReturning = false; // 是否正在返航中
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -201,6 +207,147 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 暂停任务
|
||||
Future<void> _pauseTask() async {
|
||||
if (_detailTask == null || _detailTask!.sn.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('设备序列号不存在')));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
print('🔍 [DroneMissionControl] 开始暂停任务, deviceSn: ${_detailTask!.sn}');
|
||||
|
||||
final useCase = GetIt.I<PauseFlightTaskUseCase>();
|
||||
final result = await useCase.execute(
|
||||
deviceSn: _detailTask!.sn,
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) {
|
||||
print('❌ [DroneMissionControl] 暂停任务失败: ${failure.message}');
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('暂停任务失败: ${failure.message}')));
|
||||
},
|
||||
(data) {
|
||||
print('✅ [DroneMissionControl] 任务暂停成功: $data');
|
||||
setState(() {
|
||||
_isPaused = true; // 更新状态为已暂停
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('任务已暂停')));
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
print('❌ [DroneMissionControl] 暂停任务异常: $e');
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('暂停任务失败: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 恢复任务
|
||||
Future<void> _resumeTask() async {
|
||||
if (_detailTask == null || _detailTask!.uuid.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('任务ID不存在')));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
print('🔍 [DroneMissionControl] 开始恢复任务: ${_detailTask!.uuid}');
|
||||
|
||||
final useCase = GetIt.I<UpdateFlightTaskStatusUseCase>();
|
||||
final result = await useCase.execute(
|
||||
taskId: _detailTask!.uuid,
|
||||
status: 'restored',
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) {
|
||||
print('❌ [DroneMissionControl] 恢复任务失败: ${failure.message}');
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('恢复任务失败: ${failure.message}')));
|
||||
},
|
||||
(data) {
|
||||
print('✅ [DroneMissionControl] 任务恢复成功: $data');
|
||||
setState(() {
|
||||
_isPaused = false; // 更新状态为执行中
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('任务已恢复执行')));
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
print('❌ [DroneMissionControl] 恢复任务异常: $e');
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('恢复任务失败: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 返航降落
|
||||
Future<void> _returnHome() async {
|
||||
if (_detailTask == null || _detailTask!.sn.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('设备序列号不存在')));
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复点击
|
||||
if (_isReturning) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('已在返航中...')));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
print('🔍 [DroneMissionControl] 开始返航, deviceSn: ${_detailTask!.sn}');
|
||||
|
||||
setState(() {
|
||||
_isReturning = true; // 标记正在返航
|
||||
});
|
||||
|
||||
final useCase = GetIt.I<ReturnHomeUseCase>();
|
||||
final result = await useCase.execute(deviceSn: _detailTask!.sn);
|
||||
|
||||
result.fold(
|
||||
(failure) {
|
||||
print('❌ [DroneMissionControl] 返航失败: ${failure.message}');
|
||||
setState(() {
|
||||
_isReturning = false; // 重置状态
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('返航失败: ${failure.message}')));
|
||||
},
|
||||
(data) {
|
||||
print('✅ [DroneMissionControl] 返航指令发送成功: $data');
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('已在返航')));
|
||||
// 注意:不重置 _isReturning,因为返航是一个持续过程
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
print('❌ [DroneMissionControl] 返航异常: $e');
|
||||
setState(() {
|
||||
_isReturning = false; // 重置状态
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('返航失败: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -588,9 +735,9 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {},
|
||||
onPressed: _isPaused ? _resumeTask : _pauseTask,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFF7D00),
|
||||
backgroundColor: _isPaused ? const Color(0xFF165DFF) : const Color(0xFFFF7D00),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -598,27 +745,27 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'暂停任务',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||
child: Text(
|
||||
_isPaused ? '恢复任务' : '暂停任务',
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {},
|
||||
onPressed: _isReturning ? null : _returnHome,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF4E5969),
|
||||
side: const BorderSide(color: Color(0xFFC9CDD4)),
|
||||
foregroundColor: _isReturning ? const Color(0xFF86909C) : const Color(0xFF4E5969),
|
||||
side: BorderSide(color: _isReturning ? const Color(0xFFE5E6EB) : const Color(0xFFC9CDD4)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'返航降落',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||
child: Text(
|
||||
_isReturning ? '已在返航' : '返航降落',
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -32,6 +32,9 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
|
||||
// 无人机状态轮询计时器
|
||||
Timer? _droneStatusPollingTimer;
|
||||
|
||||
// 🔥 标记是否已经初始化过(用于判断是否从其他页面返回)
|
||||
bool _hasInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -43,12 +46,45 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
deviceSn: widget.station.deviceSn,
|
||||
),
|
||||
);
|
||||
|
||||
// 🔥 标记已初始化
|
||||
_hasInitialized = true;
|
||||
|
||||
// 启动无人机状态轮询(每5秒刷新一次)
|
||||
// 🔥 已禁用自动轮询,改为手动下拉刷新
|
||||
// _startDroneStatusPolling();
|
||||
}
|
||||
|
||||
/// 🔥 页面重新激活时调用(从其他页面返回时)
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
// 🔥 只有在已经初始化后才执行刷新(避免首次加载时重复刷新)
|
||||
if (_hasInitialized && _bloc.state is UAVDetailLoaded) {
|
||||
debugPrint('🔄 [DroneStationDetailPage] 从其他页面返回,刷新数据');
|
||||
// 延迟一下再刷新,避免与 Bloc 状态更新冲突
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
if (mounted) {
|
||||
_refreshData();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 刷新数据(无人机详情 + OSD数据会自动通过MQTT更新)
|
||||
void _refreshData() {
|
||||
if (!mounted) return;
|
||||
|
||||
debugPrint('📡 [DroneStationDetailPage] 刷新无人机详情数据');
|
||||
_bloc.add(
|
||||
UAVDetailLoad(
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
deviceSn: widget.station.deviceSn,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.close();
|
||||
@@ -216,6 +252,20 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
|
||||
/// 处理下拉刷新
|
||||
Future<void> _handleRefresh() async {
|
||||
debugPrint('🔄 [DroneStationDetailPage] 开始下拉刷新');
|
||||
|
||||
// 🔥 创建一个 Completer 来等待 Bloc 状态更新
|
||||
final completer = Completer<void>();
|
||||
|
||||
// 监听 Bloc 状态变化
|
||||
final subscription = _bloc.stream.listen((state) {
|
||||
if (state is UAVDetailLoaded || state is UAVDetailError) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 重新加载无人机详情
|
||||
_bloc.add(
|
||||
UAVDetailLoad(
|
||||
@@ -223,9 +273,19 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
deviceSn: widget.station.deviceSn,
|
||||
),
|
||||
);
|
||||
// 重置轮询计时器,使用新的状态
|
||||
// 🔥 已禁用自动轮询,无需重置
|
||||
// _scheduleDroneStatusPoll();
|
||||
|
||||
// 🔥 等待数据加载完成(最多等待5秒)
|
||||
await completer.future.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () {
|
||||
debugPrint('⚠️ [DroneStationDetailPage] 下拉刷新超时');
|
||||
},
|
||||
);
|
||||
|
||||
// 取消订阅
|
||||
subscription.cancel();
|
||||
|
||||
debugPrint('✅ [DroneStationDetailPage] 下拉刷新完成');
|
||||
}
|
||||
|
||||
Widget _buildMonitorCard() {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:volc_engine_rtc/volc_engine_rtc.dart' as volc;
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart';
|
||||
import '../../../../../core/network/mqtt/domain/entities/drone_osd_entity.dart';
|
||||
import '../../domain/entities/uav_video_stream_entity.dart';
|
||||
import '../../domain/entities/drone_station_entity.dart';
|
||||
import '../bloc/drone_station_bloc.dart';
|
||||
@@ -53,11 +59,23 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
final volc.IRTCRoomEventHandler _roomEventHandler =
|
||||
volc.IRTCRoomEventHandler();
|
||||
|
||||
// 🔥 实时轨迹相关状态
|
||||
MapController? _mapController;
|
||||
List<LatLng> _trajectoryPoints = [];
|
||||
LatLng? _currentPosition;
|
||||
double? _currentHeading;
|
||||
StreamSubscription<DroneOsdEntity>? _osdSubscription;
|
||||
DroneOsdDataSource? _droneOsdDataSource;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = sl<DroneStationBloc>();
|
||||
|
||||
// 🔥 初始化 MQTT OSD 数据源
|
||||
_droneOsdDataSource = sl<DroneOsdDataSource>();
|
||||
_startOsdListening();
|
||||
|
||||
// 初始化事件处理器
|
||||
_initVolcEventHandlers();
|
||||
|
||||
@@ -110,6 +128,9 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_osdSubscription?.cancel();
|
||||
_droneOsdDataSource?.dispose();
|
||||
_mapController?.dispose();
|
||||
_destroyRtcEngine();
|
||||
_bloc.close();
|
||||
super.dispose();
|
||||
@@ -219,6 +240,147 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 开始监听 MQTT OSD 数据
|
||||
void _startOsdListening() async {
|
||||
if (_droneOsdDataSource == null) return;
|
||||
|
||||
debugPrint('🛸 [DroneVideoControlPage] 开始监听 OSD 数据');
|
||||
debugPrint(' droneSn: ${widget.droneSn}');
|
||||
debugPrint(' gatewaySn: ${widget.gatewaySn}');
|
||||
|
||||
await _droneOsdDataSource!.startListening(
|
||||
deviceSn: widget.droneSn,
|
||||
gatewaySn: widget.gatewaySn,
|
||||
);
|
||||
|
||||
_osdSubscription = _droneOsdDataSource!.droneOsdStream.listen((osdData) {
|
||||
if (!mounted) return;
|
||||
debugPrint('📡 [DroneVideoControlPage] 收到 droneOsdStream 事件');
|
||||
_handleOsdUpdate(osdData);
|
||||
}, onError: (error) {
|
||||
debugPrint('❌ [DroneVideoControlPage] OSD 监听错误: $error');
|
||||
});
|
||||
|
||||
debugPrint('✅ [DroneVideoControlPage] OSD 监听已启动');
|
||||
}
|
||||
|
||||
/// 🔥 处理 OSD 数据更新,绘制轨迹
|
||||
void _handleOsdUpdate(DroneOsdEntity osdData) {
|
||||
// 从 rawData 中提取位置信息
|
||||
final rawData = osdData.rawData;
|
||||
|
||||
// 🔥 尝试从嵌套结构中获取经纬度
|
||||
double? lat;
|
||||
double? lng;
|
||||
double? heading;
|
||||
|
||||
// 路径1: rawData['data']['host']['99-0-0']['measure_target_latitude'] (无人机)
|
||||
if (rawData['data'] is Map &&
|
||||
(rawData['data'] as Map)['host'] is Map) {
|
||||
final host = (rawData['data'] as Map)['host'] as Map;
|
||||
|
||||
// 尝试从 99-0-0 载荷获取(无人机)
|
||||
if (host.containsKey('99-0-0') && host['99-0-0'] is Map) {
|
||||
final payload = host['99-0-0'] as Map;
|
||||
lat = (payload['measure_target_latitude'] as num?)?.toDouble();
|
||||
lng = (payload['measure_target_longitude'] as num?)?.toDouble();
|
||||
debugPrint('✅ [DroneVideoControlPage] 从 99-0-0 获取位置: lat=$lat, lng=$lng');
|
||||
}
|
||||
|
||||
// 如果 99-0-0 中没有,尝试从 host 直接获取(机场)
|
||||
if (lat == null || lng == null) {
|
||||
lat = (host['latitude'] as num?)?.toDouble();
|
||||
lng = (host['longitude'] as num?)?.toDouble();
|
||||
if (lat != null && lng != null) {
|
||||
debugPrint('✅ [DroneVideoControlPage] 从 host 获取位置: lat=$lat, lng=$lng');
|
||||
}
|
||||
}
|
||||
|
||||
// 获取航向角
|
||||
heading = (host['attitude_head'] as num?)?.toDouble() ??
|
||||
(host['heading'] as num?)?.toDouble();
|
||||
}
|
||||
|
||||
// 兼容旧格式:直接从 rawData 获取
|
||||
if (lat == null || lng == null) {
|
||||
lat = lat ?? (rawData['latitude'] as num?)?.toDouble() ??
|
||||
(rawData['lat'] as num?)?.toDouble();
|
||||
lng = lng ?? (rawData['longitude'] as num?)?.toDouble() ??
|
||||
(rawData['lng'] as num?)?.toDouble() ??
|
||||
(rawData['lon'] as num?)?.toDouble();
|
||||
heading = heading ?? (rawData['heading'] as num?)?.toDouble() ??
|
||||
(rawData['attitudeHeading'] as num?)?.toDouble();
|
||||
}
|
||||
|
||||
debugPrint('🛰️ [DroneVideoControlPage] 收到 OSD 数据');
|
||||
debugPrint(' lat=$lat, lng=$lng, heading=$heading');
|
||||
debugPrint(' 当前轨迹点数: ${_trajectoryPoints.length}');
|
||||
debugPrint(' 当前位置: $_currentPosition');
|
||||
|
||||
// 验证位置有效性
|
||||
if (lat != null && lng != null && lat.abs() <= 90 && lng.abs() <= 180) {
|
||||
final newPos = LatLng(lat, lng);
|
||||
|
||||
setState(() {
|
||||
// 更新当前位置(驱动飞机 Marker)
|
||||
_currentPosition = newPos;
|
||||
_currentHeading = heading;
|
||||
|
||||
// 轨迹"拉烟"逻辑:距离过滤(防止 GPS 抖动导致轨迹像乱麻)
|
||||
if (_trajectoryPoints.isEmpty) {
|
||||
_trajectoryPoints.add(newPos);
|
||||
debugPrint('✅ [DroneVideoControlPage] 添加第一个轨迹点: $newPos');
|
||||
|
||||
// 🔥 重要:第一个点添加后,等待 UI 构建完成再移动地图
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_mapController != null && mounted) {
|
||||
_mapController!.move(newPos, 18); // 缩放到 18 级
|
||||
debugPrint('🗺️ [DroneVideoControlPage] 首次定位到: $newPos');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
final distance = _calculateDistance(
|
||||
_trajectoryPoints.last.latitude,
|
||||
_trajectoryPoints.last.longitude,
|
||||
lat!, // ✅ 空安全:已经通过 if 检查,使用 ! 断言
|
||||
lng!, // ✅ 空安全:已经通过 if 检查,使用 ! 断言
|
||||
);
|
||||
debugPrint(' 📏 距离上一个点: ${distance.toStringAsFixed(2)} 米');
|
||||
// 只有移动超过 0.5 米才画线,否则认为是原地漂移
|
||||
if (distance > 0.5) {
|
||||
_trajectoryPoints.add(newPos);
|
||||
debugPrint('✅ [DroneVideoControlPage] 添加新轨迹点,当前总数: ${_trajectoryPoints.length}');
|
||||
// 性能优化:只保留最近 1000 个点
|
||||
if (_trajectoryPoints.length > 1000) {
|
||||
_trajectoryPoints.removeAt(0);
|
||||
}
|
||||
} else {
|
||||
debugPrint('⚠️ [DroneVideoControlPage] 距离不足0.5米(${distance.toStringAsFixed(2)}m),跳过此点');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 地图跟随:后续点也移动地图(保持飞机在视野中)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_mapController != null && mounted) {
|
||||
_mapController!.move(newPos, _mapController!.camera.zoom);
|
||||
debugPrint('🗺️ [DroneVideoControlPage] 地图已移动到: $newPos');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
debugPrint('⚠️ [DroneVideoControlPage] 位置数据无效: lat=$lat, lng=$lng');
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 计算两点之间的距离(米)
|
||||
double _calculateDistance(double lat1, double lon1, double lat2, double lon2) {
|
||||
const p = 0.017453292519943295; // Math.PI / 180
|
||||
final a = 0.5 -
|
||||
cos((lat2 - lat1) * p) / 2 +
|
||||
cos(lat1 * p) * cos(lat2 * p) * (1 - cos((lon2 - lon1) * p)) / 2;
|
||||
return 12742 * asin(sqrt(a)) * 1000; // 2 * R * asin(sqrt(a)) * 1000 (R = 6371km)
|
||||
}
|
||||
|
||||
// 初始化火山引擎事件处理器
|
||||
void _initVolcEventHandlers() {
|
||||
_engineEventHandler.onWarning = (volc.WarningCode code) {
|
||||
@@ -522,7 +684,7 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
}
|
||||
} else if (state is UavVideoStreamError) {
|
||||
setState(() {
|
||||
_errorMessage = state.message;
|
||||
_errorMessage = '暂无视频';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -537,11 +699,15 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
children: [
|
||||
_buildVideoPlayer(),
|
||||
const SizedBox(height: 12),
|
||||
// 🔥 实时轨迹地图(放在视频和飞行数据之间)
|
||||
_buildTrajectoryMap(),
|
||||
const SizedBox(height: 12),
|
||||
_buildFlightData(),
|
||||
const SizedBox(height: 12),
|
||||
_buildAIResults(),
|
||||
const SizedBox(height: 12),
|
||||
_buildMapAndJoystick(),
|
||||
// 🔥 摇杆控制(单独一行)
|
||||
_buildJoystickControl(),
|
||||
const SizedBox(height: 16),
|
||||
_buildBottomToolbar(),
|
||||
],
|
||||
@@ -901,29 +1067,351 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔥 实时轨迹地图(放在视频和飞行数据之间)
|
||||
Widget _buildTrajectoryMap() {
|
||||
debugPrint('🗺️ [DroneVideoControlPage] 构建地图轨迹组件');
|
||||
debugPrint(' currentPosition: $_currentPosition');
|
||||
debugPrint(' currentHeading: $_currentHeading');
|
||||
debugPrint(' trajectoryPoints.length: ${_trajectoryPoints.length}');
|
||||
|
||||
return Container(
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 🔥 地图
|
||||
FlutterMap(
|
||||
mapController: _mapController ??= MapController(), // ✅ 懒加载初始化
|
||||
options: MapOptions(
|
||||
initialCenter: _currentPosition ?? const LatLng(39.9042, 116.4074),
|
||||
initialZoom: 18,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
// 高德地图瓦片(最底层)
|
||||
TileLayer(
|
||||
urlTemplate: 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
|
||||
subdomains: ['1', '2', '3', '4'],
|
||||
userAgentPackageName: 'com.example.app',
|
||||
),
|
||||
|
||||
// 🔥 轨迹线(中间层)
|
||||
if (_trajectoryPoints.length >= 1)
|
||||
PolylineLayer(
|
||||
polylines: [
|
||||
Polyline(
|
||||
points: _trajectoryPoints,
|
||||
color: const Color(0xFF00B42A).withOpacity(0.9),
|
||||
strokeWidth: 4,
|
||||
borderColor: Colors.white,
|
||||
borderStrokeWidth: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 🔥 无人机当前位置标记(最上层)
|
||||
if (_currentPosition != null)
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
key: const ValueKey('drone_marker'),
|
||||
point: _currentPosition!,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Transform.rotate(
|
||||
angle: (_currentHeading ?? 0) * pi / 180,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.flight,
|
||||
color: Color(0xFF165DFF),
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 🔥 GPS定位标签
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'GPS定位',
|
||||
style: TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 🔥 轨迹点数
|
||||
if (_trajectoryPoints.isNotEmpty)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'轨迹点: ${_trajectoryPoints.length}',
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 🔥 等待定位提示
|
||||
if (_currentPosition == null)
|
||||
const Positioned(
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
child: Text(
|
||||
'等待定位...',
|
||||
style: TextStyle(fontSize: 10, color: Colors.black54),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔥 摇杆控制(单独一行)
|
||||
Widget _buildJoystickControl() {
|
||||
return Container(
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC9CDD4),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 16,
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 32,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 32,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_left,
|
||||
size: 32,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_right,
|
||||
size: 32,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMapAndJoystick() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 160,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
color: const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.asset(
|
||||
'assets/images/xunjian.png',
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 🔥 地图
|
||||
FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: _currentPosition ?? const LatLng(39.9042, 116.4074),
|
||||
initialZoom: 18,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
// 高德地图瓦片(最底层)
|
||||
TileLayer(
|
||||
urlTemplate: 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
|
||||
subdomains: ['1', '2', '3', '4'],
|
||||
userAgentPackageName: 'com.example.app',
|
||||
),
|
||||
|
||||
// 🔥 轨迹线(中间层)
|
||||
if (_trajectoryPoints.length >= 1)
|
||||
PolylineLayer(
|
||||
polylines: [
|
||||
Polyline(
|
||||
points: _trajectoryPoints,
|
||||
color: const Color(0xFF00B42A).withOpacity(0.9),
|
||||
strokeWidth: 4,
|
||||
borderColor: Colors.white,
|
||||
borderStrokeWidth: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 🔥 无人机当前位置标记(最上层)
|
||||
if (_currentPosition != null)
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
key: const ValueKey('drone_marker'),
|
||||
point: _currentPosition!,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Transform.rotate(
|
||||
angle: (_currentHeading ?? 0) * pi / 180,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.flight,
|
||||
color: Color(0xFF165DFF),
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 🔥 GPS定位标签
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'GPS定位',
|
||||
style: TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 🔥 轨迹点数
|
||||
if (_trajectoryPoints.isNotEmpty)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'轨迹点: ${_trajectoryPoints.length}',
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 🔥 等待定位提示
|
||||
if (_currentPosition == null)
|
||||
const Positioned(
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
child: Text(
|
||||
'等待定位...',
|
||||
style: TextStyle(fontSize: 10, color: Colors.black54),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -931,7 +1419,7 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 160,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
||||
@@ -25,13 +25,32 @@ class RobotControlPage extends StatelessWidget {
|
||||
icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2129)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
'${robot['type']}控制',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
title: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
robot['alias'] != null && (robot['alias'] as String).isNotEmpty
|
||||
? robot['alias']
|
||||
: '暂无别名',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
robot['name'] ?? '', // 🔥 使用 name 字段(长序列号 deviceName)
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
|
||||
@@ -112,7 +112,8 @@ class _RobotListViewState extends State<RobotListView> {
|
||||
.where((r) => r.status == '在线')
|
||||
.length;
|
||||
final onlineCleaning = cleaningRobots.where((r) => r.status == '在线').length;
|
||||
final onlineWeeding = weedingRobots.where((r) => r.status == '在线').length;
|
||||
// 暂且所有设备都视为除草机器人,在线数使用总数
|
||||
final onlineWeeding = allRobots.where((r) => r.status == '在线').length;
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
@@ -351,7 +352,7 @@ class _RobotListViewState extends State<RobotListView> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${weedingRobots.length}',
|
||||
'${allRobots.length}',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -70,7 +70,10 @@ class _DroneOsdCardState extends State<DroneOsdCard> {
|
||||
_subscription = _dataSource.droneOsdStream.listen((osd) {
|
||||
if (!mounted) return;
|
||||
|
||||
debugPrint('📥 [DroneOsdCard] 收到无人机 OSD 数据');
|
||||
// 🔥 打印完整的 MQTT 原始数据(不做任何解析)
|
||||
debugPrint('\n========== 📥 [无人机OSD] 完整原始数据 ==========');
|
||||
debugPrint('${osd.rawData}');
|
||||
debugPrint('===========================================\n');
|
||||
|
||||
setState(() {
|
||||
_currentOsd = osd;
|
||||
@@ -115,7 +118,9 @@ class _DroneOsdCardState extends State<DroneOsdCard> {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('📊 [DroneOsdCard] 数据键: ${droneData.keys.toList()}');
|
||||
// 🔥 打印完整原始数据(用于调试)
|
||||
debugPrint('📊 [DroneOsdCard] drone/host 数据键: ${droneData.keys.toList()}');
|
||||
debugPrint('📊 [DroneOsdCard] 完整原始数据: $droneData');
|
||||
|
||||
// ========== 1. 基础飞行信息 ==========
|
||||
// 无人机高度(height / altitude)
|
||||
@@ -321,28 +326,28 @@ class _DroneOsdCardState extends State<DroneOsdCard> {
|
||||
? const Color(0xFF00B42A)
|
||||
: const Color(0xFFFF7D00),
|
||||
},
|
||||
{
|
||||
'icon': Icons.signal_cellular_alt,
|
||||
'label': '遥控信号',
|
||||
'key': 'rcSignal',
|
||||
'newValue': rcSignal != null ? '$rcSignal%' : null,
|
||||
'color': rcSignal != null && rcSignal > 80
|
||||
? const Color(0xFF00B42A)
|
||||
: rcSignal != null && rcSignal > 50
|
||||
? const Color(0xFFFF7D00)
|
||||
: const Color(0xFFF53F3F),
|
||||
},
|
||||
{
|
||||
'icon': Icons.video_label,
|
||||
'label': '图传信号',
|
||||
'key': 'videoSignal',
|
||||
'newValue': videoSignal != null ? '$videoSignal%' : null,
|
||||
'color': videoSignal != null && videoSignal > 80
|
||||
? const Color(0xFF00B42A)
|
||||
: videoSignal != null && videoSignal > 50
|
||||
? const Color(0xFFFF7D00)
|
||||
: const Color(0xFFF53F3F),
|
||||
},
|
||||
// {
|
||||
// 'icon': Icons.signal_cellular_alt,
|
||||
// 'label': '遥控信号',
|
||||
// 'key': 'rcSignal',
|
||||
// 'newValue': rcSignal != null ? '$rcSignal%' : null,
|
||||
// 'color': rcSignal != null && rcSignal > 80
|
||||
// ? const Color(0xFF00B42A)
|
||||
// : rcSignal != null && rcSignal > 50
|
||||
// ? const Color(0xFFFF7D00)
|
||||
// : const Color(0xFFF53F3F),
|
||||
// },
|
||||
// {
|
||||
// 'icon': Icons.video_label,
|
||||
// 'label': '图传信号',
|
||||
// 'key': 'videoSignal',
|
||||
// 'newValue': videoSignal != null ? '$videoSignal%' : null,
|
||||
// 'color': videoSignal != null && videoSignal > 80
|
||||
// ? const Color(0xFF00B42A)
|
||||
// : videoSignal != null && videoSignal > 50
|
||||
// ? const Color(0xFFFF7D00)
|
||||
// : const Color(0xFFF53F3F),
|
||||
// },
|
||||
{
|
||||
'icon': Icons.flight,
|
||||
'label': '飞行模式',
|
||||
@@ -350,22 +355,22 @@ class _DroneOsdCardState extends State<DroneOsdCard> {
|
||||
'newValue': flightMode,
|
||||
'color': const Color(0xFF165DFF),
|
||||
},
|
||||
{
|
||||
'icon': Icons.radio_button_checked,
|
||||
'label': '任务进度',
|
||||
'key': 'mission',
|
||||
'newValue': missionProgress != null ? '$missionProgress%' : null,
|
||||
'color': const Color(0xFF00B42A),
|
||||
},
|
||||
{
|
||||
'icon': Icons.map,
|
||||
'label': '航点',
|
||||
'key': 'waypoint',
|
||||
'newValue': (currentWaypoint != null && waypointCount != null)
|
||||
? '$currentWaypoint/$waypointCount'
|
||||
: null,
|
||||
'color': const Color(0xFF722ED1),
|
||||
},
|
||||
// {
|
||||
// 'icon': Icons.radio_button_checked,
|
||||
// 'label': '任务进度',
|
||||
// 'key': 'mission',
|
||||
// 'newValue': missionProgress != null ? '$missionProgress%' : null,
|
||||
// 'color': const Color(0xFF00B42A),
|
||||
// },
|
||||
// {
|
||||
// 'icon': Icons.map,
|
||||
// 'label': '航点',
|
||||
// 'key': 'waypoint',
|
||||
// 'newValue': (currentWaypoint != null && waypointCount != null)
|
||||
// ? '$currentWaypoint/$waypointCount'
|
||||
// : null,
|
||||
// 'color': const Color(0xFF722ED1),
|
||||
// },
|
||||
];
|
||||
|
||||
// 应用缓存逻辑:有新值则更新缓存,否则使用旧值
|
||||
|
||||
@@ -135,10 +135,9 @@ class DroneStationItemCard extends StatelessWidget {
|
||||
// ],
|
||||
],
|
||||
),
|
||||
//
|
||||
// const SizedBox(height: 12),
|
||||
//
|
||||
// // 电量卡片
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 🔥 电量状态 - 已隐藏
|
||||
// _buildInfoCard(
|
||||
// title: '电量状态',
|
||||
// icon: Icons.battery_full_rounded,
|
||||
@@ -146,10 +145,10 @@ class DroneStationItemCard extends StatelessWidget {
|
||||
// value: station.capacityPercent != null ? '${station.capacityPercent}%' : '未知',
|
||||
// progressValue: station.capacityPercent,
|
||||
// ),
|
||||
//
|
||||
|
||||
// const SizedBox(height: 12),
|
||||
//
|
||||
// // 环境信息卡片
|
||||
|
||||
// 🔥 环境监测 - 已隐藏
|
||||
// _buildSectionCard(
|
||||
// title: '环境监测',
|
||||
// icon: Icons.thermostat_rounded,
|
||||
|
||||
@@ -66,7 +66,10 @@ class _DroneStationOsdCardState extends State<DroneStationOsdCard> {
|
||||
_subscription = _dataSource.stationOsdStream.listen((osd) {
|
||||
if (!mounted) return;
|
||||
|
||||
//debugPrint('📥 [DroneStationOsdCard] 收到机场 OSD 原始数据');
|
||||
// 🔥 打印完整的 MQTT 原始数据(不做任何解析)
|
||||
debugPrint('\n========== 📥 [机场OSD] 完整原始数据 ==========');
|
||||
debugPrint('${osd.rawData}');
|
||||
debugPrint('===========================================\n');
|
||||
|
||||
setState(() {
|
||||
_currentOsd = osd;
|
||||
@@ -95,8 +98,11 @@ class _DroneStationOsdCardState extends State<DroneStationOsdCard> {
|
||||
return;
|
||||
}
|
||||
|
||||
// debugPrint('📊 [DroneStationOsdCard] 开始解析 OSD 数据...');
|
||||
//debugPrint('📊 [DroneStationOsdCard] host 数据键: ${hostData.keys.toList()}');
|
||||
// 🔥 打印完整的 host 数据键,查看所有可用字段
|
||||
debugPrint('📊 [DroneStationOsdCard] host 数据键: ${hostData.keys.toList()}');
|
||||
|
||||
// 🔥 打印完整原始数据(用于调试)
|
||||
debugPrint('📊 [DroneStationOsdCard] 完整原始数据: $hostData');
|
||||
|
||||
// ========== 1. 电池相关 ==========
|
||||
// 提取无人机电量(从 drone_battery_maintenance_info.batteries[0].capacity_percent)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_state.dart'; // 🔥 导入 AppUserState
|
||||
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
|
||||
import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart';
|
||||
|
||||
@@ -15,7 +16,6 @@ class RobotHeaderCard extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _RobotHeaderCardState extends State<RobotHeaderCard> {
|
||||
String _videoStreamUrl = '';
|
||||
int _currentViewIndex = 0; // 0=前, 1=后, 2=左, 3=右, 4=上
|
||||
|
||||
// 视角配置
|
||||
@@ -30,30 +30,7 @@ class _RobotHeaderCardState extends State<RobotHeaderCard> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initVideoUrl();
|
||||
}
|
||||
|
||||
/// 初始化视频流 URL
|
||||
void _initVideoUrl() {
|
||||
final userState = context.read<AppUserCubit>().state;
|
||||
final deviceId = widget.robot['id'] as String?;
|
||||
|
||||
debugPrint(' [RobotHeaderCard] 开始初始化视频URL');
|
||||
debugPrint('🎬 [RobotHeaderCard] deviceId: $deviceId');
|
||||
debugPrint('🎬 [RobotHeaderCard] user: ${userState.user}');
|
||||
debugPrint('🎬 [RobotHeaderCard] token: ${userState.user?.token}');
|
||||
|
||||
if (deviceId != null && deviceId.isNotEmpty && userState.user != null && userState.user!.token != null) {
|
||||
setState(() {
|
||||
_videoStreamUrl = "webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=${userState.user!.token}";
|
||||
});
|
||||
debugPrint('✅ [RobotHeaderCard] 视频URL初始化成功: $_videoStreamUrl');
|
||||
} else {
|
||||
debugPrint('❌ [RobotHeaderCard] 视频URL初始化失败');
|
||||
debugPrint(' - deviceId.isEmpty: ${deviceId == null || deviceId.isEmpty}');
|
||||
debugPrint(' - user == null: ${userState.user == null}');
|
||||
debugPrint(' - token == null: ${userState.user?.token == null}');
|
||||
}
|
||||
debugPrint('🎬 [RobotHeaderCard] 初始化 - robot: ${widget.robot}');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -147,26 +124,59 @@ class _RobotHeaderCardState extends State<RobotHeaderCard> {
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
color: Colors.black, // 🔥 强制整个视频区域为黑色背景
|
||||
child: _videoStreamUrl.isNotEmpty
|
||||
? WebRTCLocalPlayer(
|
||||
streamUrl: _videoStreamUrl,
|
||||
showLeftPip: false, // 不显示悬浮小窗
|
||||
showRightPip: false,
|
||||
isFrontMain: _currentViewIndex == 0, // 根据当前视角决定主画面
|
||||
)
|
||||
: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.videocam_off, size: 48, color: Colors.white54),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'无视频信号',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
child: BlocBuilder<AppUserCubit, AppUserState>(
|
||||
builder: (context, userState) {
|
||||
final deviceId = widget.robot['name'] as String?; // 🔥 使用 name 字段(长序列号)
|
||||
String videoStreamUrl = '';
|
||||
|
||||
debugPrint('🎬 [RobotHeaderCard] BlocBuilder 重建');
|
||||
debugPrint(' - deviceId (name): $deviceId');
|
||||
debugPrint(' - user != null: ${userState.user != null}');
|
||||
debugPrint(' - token != null: ${userState.user?.token != null}');
|
||||
debugPrint(' - TCP_IP: ${TCPConsts.TCP_IP}');
|
||||
|
||||
if (deviceId != null &&
|
||||
deviceId.isNotEmpty &&
|
||||
userState.user != null &&
|
||||
userState.user!.token != null) {
|
||||
videoStreamUrl = "webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=${userState.user!.token}";
|
||||
debugPrint('✅ [RobotHeaderCard] 视频URL构建成功: $videoStreamUrl');
|
||||
} else {
|
||||
debugPrint('❌ [RobotHeaderCard] 视频URL构建失败');
|
||||
if (deviceId == null || deviceId.isEmpty) {
|
||||
debugPrint(' - 原因: deviceId 为空');
|
||||
}
|
||||
if (userState.user == null) {
|
||||
debugPrint(' - 原因: user 为 null');
|
||||
}
|
||||
if (userState.user?.token == null) {
|
||||
debugPrint(' - 原因: token 为 null');
|
||||
}
|
||||
}
|
||||
|
||||
return videoStreamUrl.isNotEmpty
|
||||
? WebRTCLocalPlayer(
|
||||
key: ValueKey(videoStreamUrl),
|
||||
streamUrl: videoStreamUrl,
|
||||
showLeftPip: false,
|
||||
showRightPip: false,
|
||||
mainViewAlignment: _viewConfigs[_currentViewIndex]['alignment'] as Alignment,
|
||||
)
|
||||
: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.videocam_off, size: 48, color: Colors.white54),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'暂无视频',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -224,3 +234,5 @@ class _RobotHeaderCardState extends State<RobotHeaderCard> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ class RobotItemCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
alias != null && alias!.isNotEmpty ? alias! : '暂无别名',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -84,13 +84,13 @@ class RobotItemCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
alias != null && alias!.isNotEmpty
|
||||
? '别名: $alias'
|
||||
: 'ID: $id',
|
||||
name, // 🔥 显示完整的长序列号 (deviceName)
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user