无人机视频对接+支持无人机未在线时候5S执行一下查询操作。便于视频正常展示。
无人机视频的摄像头切换功能和参数切换功能(广角和红外 和等) 无人机航线任务创建页面的开发和接口对接 无人机航线任务搜索接口(支持时间过滤) 无人机执行航线任务功能开发和接口对接 无人机暂停执行任务功能开发和接口对接 无人机返航任务和取消返航功能开发和接口对接 无人机机场详情页中的展示信息的更正。
This commit is contained in:
@@ -39,4 +39,22 @@ class HttpApiConsts {
|
||||
|
||||
// 获取飞行任务列表
|
||||
static const String getFlightTask = "$baseUrl/iot/UAV/getFlightTask";
|
||||
|
||||
// 获取飞行任务详情
|
||||
static const String getFlightTaskDetail = "$baseUrl/iot/UAV/getFlightTaskDetail";
|
||||
|
||||
// 获取航线列表
|
||||
static const String getWayline = "$baseUrl/iot/UAV/getWayline";
|
||||
|
||||
// 创建飞行任务
|
||||
static const String createFlightTask = "$baseUrl/iot/UAV/createFlightTask";
|
||||
|
||||
// 更新飞行任务状态
|
||||
static const String updateFlightTaskStatus = "$baseUrl/iot/UAV/updateFlightTaskStatus";
|
||||
|
||||
// 返航、暂停等命令
|
||||
static const String flightTaskCommand = "$baseUrl/iot/UAV/flightTaskCommand";
|
||||
|
||||
// 切换无人机镜头获取视频流
|
||||
static const String changeUAVLens = "$baseUrl/iot/UAV/changeLens";
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ import '../../features/v2/device_list/data/repositories/drone_station_repository
|
||||
import '../../features/v2/device_list/domain/repositories/drone_station_repository.dart';
|
||||
import '../../features/v2/device_list/domain/usecases/get_drone_station_list_usecase.dart';
|
||||
import '../../features/v2/device_list/domain/usecases/get_video_stream_usecase.dart';
|
||||
import '../../features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart';
|
||||
import '../../features/v2/device_list/domain/usecases/update_flight_task_status_usecase.dart';
|
||||
import '../../features/v2/device_list/presentation/bloc/drone_station_bloc.dart';
|
||||
import '../../features/v2/device_list/presentation/bloc/robot_list_bloc.dart';
|
||||
import '../../features/v2/waring_center/data/datasources/alarm_remote_datasource.dart';
|
||||
@@ -301,8 +303,14 @@ Future<void> init() async {
|
||||
sl.registerLazySingleton<GetVideoStreamUseCase>(
|
||||
() => GetVideoStreamUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<GetUavVideoStreamUseCase>(
|
||||
() => GetUavVideoStreamUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<UpdateFlightTaskStatusUseCase>(
|
||||
() => UpdateFlightTaskStatusUseCase(sl()),
|
||||
);
|
||||
sl.registerFactory<DroneStationBloc>(
|
||||
() => DroneStationBloc(sl(), sl(), sl()),
|
||||
() => DroneStationBloc(sl(), sl(), sl(), sl()),
|
||||
);
|
||||
|
||||
/// Robot List V2
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import '../../domain/entities/drone_station_entity.dart';
|
||||
import '../../domain/entities/video_stream_entity.dart';
|
||||
import '../../domain/entities/flight_task_entity.dart';
|
||||
import '../../domain/entities/uav_video_stream_entity.dart';
|
||||
|
||||
abstract class DroneStationDataSource {
|
||||
Future<List<DroneStationEntity>> getDroneStationList(int siteId);
|
||||
@@ -17,4 +18,20 @@ abstract class DroneStationDataSource {
|
||||
required int beginAt,
|
||||
required int endAt,
|
||||
});
|
||||
|
||||
Future<FlightTaskEntity> getFlightTaskDetail(String taskId);
|
||||
|
||||
Future<Map<String, dynamic>> updateFlightTaskStatus({
|
||||
required String taskId,
|
||||
required String status,
|
||||
});
|
||||
|
||||
/// 切换无人机镜头获取实时视频流
|
||||
Future<UavVideoStreamEntity> getUavVideoStream({
|
||||
required String sn,
|
||||
required String cameraIndex,
|
||||
UavLensType? lensType,
|
||||
VideoQualityType qualityType = VideoQualityType.adaptive,
|
||||
int videoExpire = 720000000,
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import '../datasources/drone_station_datasource.dart';
|
||||
import '../../domain/entities/drone_station_entity.dart';
|
||||
import '../../domain/entities/video_stream_entity.dart';
|
||||
import '../../domain/entities/flight_task_entity.dart';
|
||||
import '../../domain/entities/uav_video_stream_entity.dart';
|
||||
|
||||
class DroneStationDataSourceImpl implements DroneStationDataSource {
|
||||
final Dio dio;
|
||||
@@ -134,4 +136,198 @@ class DroneStationDataSourceImpl implements DroneStationDataSource {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlightTaskEntity> getFlightTaskDetail(String taskId) async {
|
||||
final response = await dio.get(
|
||||
HttpApiConsts.getFlightTaskDetail,
|
||||
queryParameters: {'taskId': taskId},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
|
||||
if (responseData['code'] != 0 && responseData['code'] != 200) {
|
||||
throw Exception(responseData['message'] ?? '业务异常');
|
||||
}
|
||||
|
||||
return FlightTaskEntity.fromJson(responseData['data']);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> updateFlightTaskStatus({
|
||||
required String taskId,
|
||||
required String status,
|
||||
}) async {
|
||||
final response = await dio.post(
|
||||
HttpApiConsts.updateFlightTaskStatus,
|
||||
queryParameters: {
|
||||
'taskId': taskId,
|
||||
'status': status,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
|
||||
if (responseData['code'] != 0) {
|
||||
throw Exception(responseData['message'] ?? '业务异常');
|
||||
}
|
||||
|
||||
return responseData;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UavVideoStreamEntity> getUavVideoStream({
|
||||
required String sn,
|
||||
required String cameraIndex,
|
||||
UavLensType? lensType,
|
||||
VideoQualityType qualityType = VideoQualityType.adaptive,
|
||||
int videoExpire = 720000000,
|
||||
}) async {
|
||||
try {
|
||||
print('=== 开始请求无人机视频流 ===');
|
||||
print('URL: ${HttpApiConsts.changeUAVLens}');
|
||||
print('📤 请求参数:');
|
||||
print(' sn: $sn');
|
||||
print(' cameraIndex: $cameraIndex ⬅️ 重点检查这个');
|
||||
print(' lensType: ${_lensTypeToString(lensType)}');
|
||||
print(' qualityType: ${_qualityTypeToString(qualityType)}');
|
||||
print(' videoExpire: $videoExpire');
|
||||
|
||||
final response = await dio.post(
|
||||
HttpApiConsts.changeUAVLens,
|
||||
data: {
|
||||
'sn': sn,
|
||||
'lensType': lensType != null ? _lensTypeToString(lensType) : '',
|
||||
'cameraIndex': cameraIndex,
|
||||
'qualityType': _qualityTypeToString(qualityType),
|
||||
'videoExpire': videoExpire,
|
||||
},
|
||||
options: Options(
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
sendTimeout: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
|
||||
print('响应状态码: ${response.statusCode}');
|
||||
print('响应数据类型: ${response.data.runtimeType}');
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
var responseData = response.data;
|
||||
|
||||
// 打印完整响应,方便调试
|
||||
print('完整响应: $responseData');
|
||||
|
||||
// 检查是否为字符串类型,需要手动解析
|
||||
if (responseData is String) {
|
||||
print('检测到响应为字符串类型,尝试 JSON 解析...');
|
||||
try {
|
||||
final parsed = jsonDecode(responseData);
|
||||
if (parsed is Map<String, dynamic>) {
|
||||
print('JSON 解析成功');
|
||||
responseData = parsed;
|
||||
} else {
|
||||
throw Exception('JSON 解析结果不是 Map 类型');
|
||||
}
|
||||
} catch (e) {
|
||||
print('JSON 解析失败: $e');
|
||||
throw Exception('响应数据格式错误: $e');
|
||||
}
|
||||
}
|
||||
|
||||
print('响应数据键列表: ${(responseData as Map).keys?.toList()}');
|
||||
|
||||
if (responseData['code'] != 200) {
|
||||
final errorMsg = responseData['msg'] ?? '业务异常';
|
||||
print('业务错误: $errorMsg');
|
||||
throw Exception(errorMsg);
|
||||
}
|
||||
|
||||
final data = responseData['data'];
|
||||
print('\n=== data 字段内容 ===');
|
||||
print('data: $data');
|
||||
print('data 类型: ${data.runtimeType}');
|
||||
|
||||
if (data == null) {
|
||||
print('错误: data 字段为 null!');
|
||||
throw Exception('服务器返回的 data 字段为空');
|
||||
}
|
||||
|
||||
print('data.sn: ${data['sn']}');
|
||||
print('data.camera_index: ${data['camera_index']}');
|
||||
print('data.url: ${data['url']}');
|
||||
print('data.expire_ts: ${data['expire_ts']}');
|
||||
print('data.url_type: ${data['url_type']}');
|
||||
print('=========================\n');
|
||||
|
||||
return UavVideoStreamEntity.fromJson(data);
|
||||
} on DioException catch (e) {
|
||||
print('=== Dio 异常捕获 ===');
|
||||
print('异常类型: ${e.type}');
|
||||
print('异常消息: ${e.message}');
|
||||
print('异常详情: $e');
|
||||
|
||||
String errorMessage;
|
||||
switch (e.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
errorMessage = '网络连接超时,请检查网络';
|
||||
break;
|
||||
case DioExceptionType.connectionError:
|
||||
errorMessage = '网络连接失败,请检查网络设置';
|
||||
break;
|
||||
case DioExceptionType.badResponse:
|
||||
errorMessage = '服务器响应错误: ${e.response?.statusCode}';
|
||||
break;
|
||||
default:
|
||||
errorMessage = '网络请求失败: ${e.message}';
|
||||
}
|
||||
|
||||
throw Exception(errorMessage);
|
||||
} catch (e) {
|
||||
print('=== 未知异常 ===');
|
||||
print('异常类型: ${e.runtimeType}');
|
||||
print('异常消息: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 将镜头类型枚举转换为字符串
|
||||
String _lensTypeToString(UavLensType? type) {
|
||||
switch (type) {
|
||||
case UavLensType.wide:
|
||||
return 'wide';
|
||||
case UavLensType.zoom:
|
||||
return 'zoom';
|
||||
case UavLensType.ir:
|
||||
return 'ir';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 将质量类型枚举转换为字符串
|
||||
String _qualityTypeToString(VideoQualityType type) {
|
||||
switch (type) {
|
||||
case VideoQualityType.adaptive:
|
||||
return 'adaptive';
|
||||
case VideoQualityType.low:
|
||||
return 'low';
|
||||
case VideoQualityType.medium:
|
||||
return 'medium';
|
||||
case VideoQualityType.high:
|
||||
return 'high';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import '../../../../../core/error/failure.dart';
|
||||
import '../datasources/drone_station_datasource.dart';
|
||||
import '../../domain/entities/drone_station_entity.dart';
|
||||
import '../../domain/entities/video_stream_entity.dart';
|
||||
import '../../domain/entities/flight_task_entity.dart';
|
||||
import '../../domain/entities/uav_video_stream_entity.dart';
|
||||
import '../../domain/repositories/drone_station_repository.dart';
|
||||
|
||||
class DroneStationRepositoryImpl implements DroneStationRepository {
|
||||
@@ -52,4 +54,70 @@ class DroneStationRepositoryImpl implements DroneStationRepository {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, Map<String, List<FlightTaskEntity>?>>> getFlightTasks({
|
||||
required List<String> sns,
|
||||
required int beginAt,
|
||||
required int endAt,
|
||||
}) async {
|
||||
try {
|
||||
final tasks = await dataSource.getFlightTasks(
|
||||
sns: sns,
|
||||
beginAt: beginAt,
|
||||
endAt: endAt,
|
||||
);
|
||||
return Right(tasks);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, FlightTaskEntity>> getFlightTaskDetail(String taskId) async {
|
||||
try {
|
||||
final detail = await dataSource.getFlightTaskDetail(taskId);
|
||||
return Right(detail);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, Map<String, dynamic>>> updateFlightTaskStatus({
|
||||
required String taskId,
|
||||
required String status,
|
||||
}) async {
|
||||
try {
|
||||
final result = await dataSource.updateFlightTaskStatus(
|
||||
taskId: taskId,
|
||||
status: status,
|
||||
);
|
||||
return Right(result);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, UavVideoStreamEntity>> getUavVideoStream({
|
||||
required String sn,
|
||||
required String cameraIndex,
|
||||
UavLensType? lensType,
|
||||
VideoQualityType qualityType = VideoQualityType.adaptive,
|
||||
int videoExpire = 720000000,
|
||||
}) async {
|
||||
try {
|
||||
final videoStream = await dataSource.getUavVideoStream(
|
||||
sn: sn,
|
||||
cameraIndex: cameraIndex,
|
||||
lensType: lensType,
|
||||
qualityType: qualityType,
|
||||
videoExpire: videoExpire,
|
||||
);
|
||||
return Right(videoStream);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,12 +76,12 @@ class PositionState extends Equatable {
|
||||
|
||||
/// UAV详情实体(用于详情页面API返回的数据)
|
||||
class UAVDetailEntity extends Equatable {
|
||||
final String deviceSn;
|
||||
final String gatewaySn;
|
||||
final String callsign;
|
||||
final String droneCallsign;
|
||||
final int onlineStatus;
|
||||
final int droneOnlineStatus;
|
||||
final String deviceSn; // 设备序列号(无人机序列号)
|
||||
final String gatewaySn; // 网关序列号
|
||||
final String callsign; // 机场呼号/名称
|
||||
final String droneCallsign; // 无人机呼号
|
||||
final int onlineStatus; // 机场在线状态 (1:在线, 0:离线)
|
||||
final int droneOnlineStatus; // 无人机在线状态
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final double? capacityPercent;
|
||||
@@ -94,7 +94,7 @@ class UAVDetailEntity extends Equatable {
|
||||
final double? liveCapacity;
|
||||
final String? rainfall;
|
||||
final List<CameraInfo>? gatewayCameraList;
|
||||
final List<dynamic>? droneCameraList;
|
||||
final List<CameraInfo>? droneCameraList;
|
||||
final int? orgId;
|
||||
final int? siteId;
|
||||
final int? userId;
|
||||
@@ -126,7 +126,12 @@ class UAVDetailEntity extends Equatable {
|
||||
|
||||
factory UAVDetailEntity.fromJson(Map<String, dynamic> json) {
|
||||
return UAVDetailEntity(
|
||||
deviceSn: json['device_sn'] ?? '',
|
||||
deviceSn:
|
||||
json['device_sn'] ??
|
||||
json['drone_sn'] ??
|
||||
json['droneSn'] ??
|
||||
json['droneDeviceSn'] ??
|
||||
'',
|
||||
gatewaySn: json['gateway_sn'] ?? '',
|
||||
callsign: json['callsign'] ?? '',
|
||||
droneCallsign: json['drone_callsign'] ?? '',
|
||||
@@ -166,7 +171,11 @@ class UAVDetailEntity extends Equatable {
|
||||
.map((item) => CameraInfo.fromJson(item))
|
||||
.toList()
|
||||
: null,
|
||||
droneCameraList: json['drone_camera_list'],
|
||||
droneCameraList: json['drone_camera_list'] != null
|
||||
? (json['drone_camera_list'] as List)
|
||||
.map((item) => CameraInfo.fromJson(item as Map<String, dynamic>))
|
||||
.toList()
|
||||
: null,
|
||||
orgId: json['orgId'],
|
||||
siteId: json['siteId'],
|
||||
userId: json['userId'],
|
||||
@@ -322,7 +331,11 @@ class DroneStationEntity extends Equatable {
|
||||
.map((item) => CameraInfo.fromJson(item))
|
||||
.toList()
|
||||
: null,
|
||||
droneCameraList: json['drone_camera_list'],
|
||||
droneCameraList: json['drone_camera_list'] != null
|
||||
? (json['drone_camera_list'] as List)
|
||||
.map((item) => CameraInfo.fromJson(item as Map<String, dynamic>))
|
||||
.toList()
|
||||
: null,
|
||||
orgId: json['orgId'] ?? 0,
|
||||
siteId: json['siteId'] ?? 0,
|
||||
userId: json['userId'],
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/// 飞行任务详情实体(专门用于详情接口)
|
||||
class FlightTaskDetailEntity {
|
||||
final String name;
|
||||
final String uuid;
|
||||
final String taskType;
|
||||
final String status;
|
||||
final String sn;
|
||||
final String droneSn; // 无人机序列号
|
||||
final String waylineUuid;
|
||||
final String beginAt;
|
||||
final String endAt;
|
||||
final String outOfControlAction; // 失控动作
|
||||
final int rthAltitude; // 返航高度(米)
|
||||
final String rthMode; // 返航模式
|
||||
final String resumableStatus; // 断点续飞状态
|
||||
final int folderId; // 文件夹ID(从folder_info中获取)
|
||||
final int expectedFileCount; // 期望文件数
|
||||
final int uploadedFileCount; // 已上传文件数
|
||||
final String repeatType; // 重复类型
|
||||
final int interval; // 间隔
|
||||
final int weekOfMonth; // 每月第几周
|
||||
final int minBatteryCapacity; // 最低电量(百分比)
|
||||
|
||||
FlightTaskDetailEntity({
|
||||
required this.name,
|
||||
required this.uuid,
|
||||
required this.taskType,
|
||||
required this.status,
|
||||
required this.sn,
|
||||
required this.droneSn,
|
||||
required this.waylineUuid,
|
||||
required this.beginAt,
|
||||
required this.endAt,
|
||||
required this.outOfControlAction,
|
||||
required this.rthAltitude,
|
||||
required this.rthMode,
|
||||
required this.resumableStatus,
|
||||
required this.folderId,
|
||||
required this.expectedFileCount,
|
||||
required this.uploadedFileCount,
|
||||
required this.repeatType,
|
||||
required this.interval,
|
||||
required this.weekOfMonth,
|
||||
required this.minBatteryCapacity,
|
||||
});
|
||||
|
||||
factory FlightTaskDetailEntity.fromJson(Map<String, dynamic> json) {
|
||||
// 安全处理 folder_info 嵌套结构
|
||||
final rawFolderInfo = json['folder_info'];
|
||||
final Map<String, dynamic> folderInfo = (rawFolderInfo is Map)
|
||||
? Map<String, dynamic>.from(rawFolderInfo)
|
||||
: <String, dynamic>{};
|
||||
print(
|
||||
'🔍 [FlightTaskDetailEntity] folder_info 类型: ${rawFolderInfo.runtimeType}',
|
||||
);
|
||||
print('🔍 [FlightTaskDetailEntity] folder_info 值: $rawFolderInfo');
|
||||
|
||||
return FlightTaskDetailEntity(
|
||||
name: json['name'] ?? '',
|
||||
uuid: json['uuid'] ?? '',
|
||||
taskType: json['task_type'] ?? '',
|
||||
status: json['status'] ?? '',
|
||||
sn: json['sn'] ?? '',
|
||||
droneSn:
|
||||
json['drone_sn'] ??
|
||||
json['device_sn'] ??
|
||||
json['droneSn'] ??
|
||||
json['deviceSn'] ??
|
||||
'',
|
||||
waylineUuid: json['wayline_uuid'] ?? '',
|
||||
beginAt: json['begin_at'] ?? '',
|
||||
endAt: json['end_at'] ?? '',
|
||||
outOfControlAction: json['out_of_control_action_in_flight'] ?? '',
|
||||
rthAltitude: _parseInt(json['rth_altitude']),
|
||||
rthMode: json['rth_mode'] ?? '',
|
||||
resumableStatus: json['resumable_status'] ?? '',
|
||||
folderId: _parseInt(folderInfo['folder_id']),
|
||||
expectedFileCount: _parseInt(folderInfo['expected_file_count']),
|
||||
uploadedFileCount: _parseInt(folderInfo['uploaded_file_count']),
|
||||
repeatType: json['repeat_type'] ?? '',
|
||||
interval: _parseInt(json['interval']),
|
||||
weekOfMonth: _parseInt(json['week_of_month']),
|
||||
minBatteryCapacity: _parseInt(json['min_battery_capacity']),
|
||||
);
|
||||
}
|
||||
|
||||
// 辅助方法:安全解析整数
|
||||
static int _parseInt(dynamic value) {
|
||||
if (value == null) return 0;
|
||||
if (value is int) return value;
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'uuid': uuid,
|
||||
'task_type': taskType,
|
||||
'status': status,
|
||||
'sn': sn,
|
||||
'drone_sn': droneSn,
|
||||
'wayline_uuid': waylineUuid,
|
||||
'begin_at': beginAt,
|
||||
'end_at': endAt,
|
||||
'out_of_control_action_in_flight': outOfControlAction,
|
||||
'rth_altitude': rthAltitude,
|
||||
'rth_mode': rthMode,
|
||||
'resumable_status': resumableStatus,
|
||||
'folder_info': {
|
||||
'folder_id': folderId,
|
||||
'expected_file_count': expectedFileCount,
|
||||
'uploaded_file_count': uploadedFileCount,
|
||||
},
|
||||
'repeat_type': repeatType,
|
||||
'interval': interval,
|
||||
'week_of_month': weekOfMonth,
|
||||
'min_battery_capacity': minBatteryCapacity,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/// 飞行任务列表实体(专门用于列表接口)
|
||||
class FlightTaskEntity {
|
||||
final String name;
|
||||
final String uuid;
|
||||
@@ -54,9 +55,9 @@ class FlightTaskEntity {
|
||||
runAt: json['run_at'] ?? '',
|
||||
completedAt: json['completed_at'] ?? '',
|
||||
waylineUuid: json['wayline_uuid'] ?? '',
|
||||
folderId: json['folder_id'] ?? 0,
|
||||
currentWaypointIndex: json['current_waypoint_index'] ?? 0,
|
||||
totalWaypoints: json['total_waypoints'] ?? 0,
|
||||
folderId: _parseInt(json['folder_id']),
|
||||
currentWaypointIndex: _parseInt(json['current_waypoint_index']),
|
||||
totalWaypoints: _parseInt(json['total_waypoints']),
|
||||
mediaUploadStatus: json['media_upload_status'] ?? '',
|
||||
resumableStatus: json['resumable_status'] ?? '',
|
||||
isBreakPointResume: json['is_break_point_resume'] ?? false,
|
||||
@@ -65,6 +66,14 @@ class FlightTaskEntity {
|
||||
);
|
||||
}
|
||||
|
||||
// 辅助方法:安全解析整数
|
||||
static int _parseInt(dynamic value) {
|
||||
if (value == null) return 0;
|
||||
if (value is int) return value;
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// 无人机镜头类型枚举
|
||||
enum UavLensType {
|
||||
wide, // 广角镜头
|
||||
zoom, // 变焦镜头
|
||||
ir, // 红外镜头
|
||||
}
|
||||
|
||||
/// 视频质量类型枚举
|
||||
enum VideoQualityType {
|
||||
adaptive, // 自适应
|
||||
low, // 低清晰度
|
||||
medium, // 中清晰度
|
||||
high, // 高清晰度
|
||||
}
|
||||
|
||||
/// 无人机实时视频流实体
|
||||
class UavVideoStreamEntity extends Equatable {
|
||||
final String sn; // 设备序列号
|
||||
final String cameraIndex; // 摄像头编号
|
||||
final String url; // 视频流URL(含RTC参数)
|
||||
final int expireTs; // Token过期时间戳
|
||||
final String urlType; // URL类型:volc(火山引擎)或 agora
|
||||
|
||||
const UavVideoStreamEntity({
|
||||
required this.sn,
|
||||
required this.cameraIndex,
|
||||
required this.url,
|
||||
required this.expireTs,
|
||||
required this.urlType,
|
||||
});
|
||||
|
||||
factory UavVideoStreamEntity.fromJson(Map<String, dynamic> json) {
|
||||
return UavVideoStreamEntity(
|
||||
sn: json['sn'] ?? '',
|
||||
cameraIndex: json['camera_index'] ?? '',
|
||||
url: json['url'] ?? '',
|
||||
expireTs: json['expire_ts'] ?? 0,
|
||||
urlType: json['url_type'] ?? 'volc',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> toMap() {
|
||||
return {
|
||||
'sn': sn,
|
||||
'cameraIndex': cameraIndex,
|
||||
'url': url,
|
||||
'expireTs': expireTs.toString(),
|
||||
'urlType': urlType,
|
||||
};
|
||||
}
|
||||
|
||||
/// 解析RTC参数(从URL中提取)
|
||||
Map<String, String> parseRtcParams() {
|
||||
final params = <String, String>{};
|
||||
final pairs = url.split('&');
|
||||
for (final pair in pairs) {
|
||||
final kv = pair.split('=');
|
||||
if (kv.length == 2) {
|
||||
params[kv[0]] = Uri.decodeComponent(kv[1]);
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/// 获取 AppId
|
||||
String get appId {
|
||||
final params = parseRtcParams();
|
||||
return params['app_id'] ?? params['appid'] ?? '';
|
||||
}
|
||||
|
||||
/// 获取 RoomId
|
||||
String get roomId {
|
||||
final params = parseRtcParams();
|
||||
return params['room_id'] ?? params['roomid'] ?? params['channel'] ?? '';
|
||||
}
|
||||
|
||||
/// 获取 Token
|
||||
String get token {
|
||||
final params = parseRtcParams();
|
||||
return params['token'] ?? '';
|
||||
}
|
||||
|
||||
/// 获取 UserId
|
||||
String get userId {
|
||||
final params = parseRtcParams();
|
||||
return params['user_id'] ?? params['uid'] ?? '';
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [sn, cameraIndex, url, expireTs, urlType];
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../../core/error/failure.dart';
|
||||
import '../entities/drone_station_entity.dart';
|
||||
import '../entities/video_stream_entity.dart';
|
||||
import '../entities/flight_task_entity.dart';
|
||||
import '../entities/uav_video_stream_entity.dart';
|
||||
|
||||
abstract class DroneStationRepository {
|
||||
Future<Either<Failure, List<DroneStationEntity>>> getDroneStationList(
|
||||
@@ -16,4 +18,23 @@ abstract class DroneStationRepository {
|
||||
required String cameraIndex,
|
||||
required String cameraPosition,
|
||||
});
|
||||
Future<Either<Failure, Map<String, List<FlightTaskEntity>?>>> getFlightTasks({
|
||||
required List<String> sns,
|
||||
required int beginAt,
|
||||
required int endAt,
|
||||
});
|
||||
Future<Either<Failure, FlightTaskEntity>> getFlightTaskDetail(String taskId);
|
||||
Future<Either<Failure, Map<String, dynamic>>> updateFlightTaskStatus({
|
||||
required String taskId,
|
||||
required String status,
|
||||
});
|
||||
|
||||
/// 获取无人机实时视频流
|
||||
Future<Either<Failure, UavVideoStreamEntity>> getUavVideoStream({
|
||||
required String sn,
|
||||
required String cameraIndex,
|
||||
UavLensType? lensType,
|
||||
VideoQualityType qualityType,
|
||||
int videoExpire,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../../core/error/failure.dart';
|
||||
import '../entities/uav_video_stream_entity.dart';
|
||||
import '../repositories/drone_station_repository.dart';
|
||||
|
||||
/// 获取无人机实时视频流用例
|
||||
class GetUavVideoStreamUseCase {
|
||||
final DroneStationRepository repository;
|
||||
|
||||
GetUavVideoStreamUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, UavVideoStreamEntity>> call({
|
||||
required String sn,
|
||||
required String cameraIndex,
|
||||
UavLensType? lensType,
|
||||
VideoQualityType qualityType = VideoQualityType.adaptive,
|
||||
int videoExpire = 720000000,
|
||||
}) async {
|
||||
return await repository.getUavVideoStream(
|
||||
sn: sn,
|
||||
cameraIndex: cameraIndex,
|
||||
lensType: lensType,
|
||||
qualityType: qualityType,
|
||||
videoExpire: videoExpire,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../../core/error/failure.dart';
|
||||
import '../repositories/drone_station_repository.dart';
|
||||
|
||||
class UpdateFlightTaskStatusUseCase {
|
||||
final DroneStationRepository repository;
|
||||
|
||||
UpdateFlightTaskStatusUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, Map<String, dynamic>>> execute({
|
||||
required String taskId,
|
||||
required String status,
|
||||
}) async {
|
||||
return await repository.updateFlightTaskStatus(
|
||||
taskId: taskId,
|
||||
status: status,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../domain/usecases/get_drone_station_list_usecase.dart';
|
||||
import '../../domain/usecases/get_video_stream_usecase.dart';
|
||||
import '../../domain/usecases/get_uav_video_stream_usecase.dart';
|
||||
import 'drone_station_event.dart';
|
||||
import 'drone_station_state.dart';
|
||||
|
||||
@@ -8,16 +9,19 @@ class DroneStationBloc extends Bloc<DroneStationEvent, DroneStationState> {
|
||||
final GetDroneStationListUseCase getDroneStationListUseCase;
|
||||
final GetUAVDetailUseCase getUAVDetailUseCase;
|
||||
final GetVideoStreamUseCase getVideoStreamUseCase;
|
||||
final GetUavVideoStreamUseCase getUavVideoStreamUseCase;
|
||||
|
||||
DroneStationBloc(
|
||||
this.getDroneStationListUseCase,
|
||||
this.getUAVDetailUseCase,
|
||||
this.getVideoStreamUseCase,
|
||||
this.getUavVideoStreamUseCase,
|
||||
) : super(const DroneStationInitial()) {
|
||||
on<DroneStationLoadData>(_onLoadData);
|
||||
on<DroneStationRefresh>(_onRefresh);
|
||||
on<UAVDetailLoad>(_onUAVDetailLoad);
|
||||
on<VideoStreamLoad>(_onVideoStreamLoad);
|
||||
on<UavVideoStreamLoad>(_onUavVideoStreamLoad);
|
||||
}
|
||||
|
||||
Future<void> _onLoadData(
|
||||
@@ -79,4 +83,29 @@ class DroneStationBloc extends Bloc<DroneStationEvent, DroneStationState> {
|
||||
(videoStream) => emit(VideoStreamLoaded(videoStream, event.cameraPosition)),
|
||||
);
|
||||
}
|
||||
|
||||
/// 处理无人机实时视频流加载事件
|
||||
Future<void> _onUavVideoStreamLoad(
|
||||
UavVideoStreamLoad event,
|
||||
Emitter<DroneStationState> emit,
|
||||
) async {
|
||||
emit(const UavVideoStreamLoading());
|
||||
|
||||
final result = await getUavVideoStreamUseCase(
|
||||
sn: event.sn,
|
||||
cameraIndex: event.cameraIndex,
|
||||
lensType: event.lensType,
|
||||
qualityType: event.qualityType,
|
||||
videoExpire: event.videoExpire,
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(UavVideoStreamError(failure.message)),
|
||||
(videoStream) => emit(UavVideoStreamLoaded(
|
||||
videoStream: videoStream,
|
||||
cameraIndex: event.cameraIndex,
|
||||
lensType: event.lensType,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../domain/entities/uav_video_stream_entity.dart';
|
||||
|
||||
abstract class DroneStationEvent extends Equatable {
|
||||
const DroneStationEvent();
|
||||
@@ -49,3 +50,23 @@ class VideoStreamLoad extends DroneStationEvent {
|
||||
@override
|
||||
List<Object?> get props => [sn, cameraIndex, cameraPosition];
|
||||
}
|
||||
|
||||
/// 加载无人机实时视频流事件
|
||||
class UavVideoStreamLoad extends DroneStationEvent {
|
||||
final String sn;
|
||||
final String cameraIndex;
|
||||
final UavLensType? lensType;
|
||||
final VideoQualityType qualityType;
|
||||
final int videoExpire;
|
||||
|
||||
const UavVideoStreamLoad({
|
||||
required this.sn,
|
||||
required this.cameraIndex,
|
||||
this.lensType,
|
||||
this.qualityType = VideoQualityType.adaptive,
|
||||
this.videoExpire = 720000000,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [sn, cameraIndex, lensType, qualityType, videoExpire];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../domain/entities/drone_station_entity.dart';
|
||||
import '../../domain/entities/video_stream_entity.dart';
|
||||
import '../../domain/entities/uav_video_stream_entity.dart';
|
||||
|
||||
abstract class DroneStationState extends Equatable {
|
||||
const DroneStationState();
|
||||
@@ -79,3 +80,34 @@ class VideoStreamError extends DroneStationState {
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// 无人机实时视频流加载状态
|
||||
class UavVideoStreamLoading extends DroneStationState {
|
||||
const UavVideoStreamLoading();
|
||||
}
|
||||
|
||||
/// 无人机实时视频流加载成功状态
|
||||
class UavVideoStreamLoaded extends DroneStationState {
|
||||
final UavVideoStreamEntity videoStream;
|
||||
final String cameraIndex;
|
||||
final UavLensType? lensType;
|
||||
|
||||
const UavVideoStreamLoaded({
|
||||
required this.videoStream,
|
||||
required this.cameraIndex,
|
||||
this.lensType,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [videoStream, cameraIndex, lensType];
|
||||
}
|
||||
|
||||
/// 无人机实时视频流加载失败状态
|
||||
class UavVideoStreamError extends DroneStationState {
|
||||
final String message;
|
||||
|
||||
const UavVideoStreamError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../../../core/consts/http_api_consts.dart';
|
||||
|
||||
class CreateTaskPage extends StatefulWidget {
|
||||
final String sn;
|
||||
|
||||
const CreateTaskPage({super.key, required this.sn});
|
||||
|
||||
@override
|
||||
State<CreateTaskPage> createState() => _CreateTaskPageState();
|
||||
}
|
||||
|
||||
class _CreateTaskPageState extends State<CreateTaskPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// 表单字段控制器
|
||||
final TextEditingController _taskNameController = TextEditingController();
|
||||
final TextEditingController _rthAltitudeController = TextEditingController(
|
||||
text: '10',
|
||||
);
|
||||
final TextEditingController _minBatteryController = TextEditingController(
|
||||
text: '50',
|
||||
);
|
||||
|
||||
// 下拉选择值
|
||||
String? _selectedWaylineName; // 航线名称(显示用)
|
||||
String? _selectedWaylineUuid; // 航线UUID(接口用)
|
||||
String _rthMode = '智能'; // 返航模式:智能/预设
|
||||
String _precision = 'GPS'; // 航线精度:GPS/RTK
|
||||
String _breakPointResume = '自动'; // 断点续飞:自动/手动
|
||||
String _taskType = '立即'; // 任务类型:立即
|
||||
|
||||
// 航线列表(name + uuid)
|
||||
List<Map<String, dynamic>> _waylineList = [];
|
||||
bool _isLoadingWaylines = false;
|
||||
final Dio _dio = Dio();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadWaylines();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_taskNameController.dispose();
|
||||
_rthAltitudeController.dispose();
|
||||
_minBatteryController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F6F8),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2129)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'创建航线任务',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 第一行:航线名称 + 选择航线
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
label: '航线名称 *',
|
||||
controller: _taskNameController,
|
||||
hint: '请输入',
|
||||
validator: (value) =>
|
||||
value == null || value.isEmpty ? '请输入航线名称' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildSelectableField(
|
||||
label: '选择航线 *',
|
||||
value: _selectedWaylineName,
|
||||
hint: '请选择航线',
|
||||
onTap: _showWaylineSelector,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 第二行:机场SN + 降落地场SN
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
label: '机场SN *',
|
||||
controller: TextEditingController(text: widget.sn),
|
||||
enabled: false,
|
||||
hint: '固定就是当前机场的sn',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
label: '降落地场SN',
|
||||
controller: TextEditingController(),
|
||||
hint: '请输入',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 第三行:返航高度 + 返航模式
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
label: '返航高度(m)',
|
||||
controller: _rthAltitudeController,
|
||||
keyboardType: TextInputType.number,
|
||||
hint: '默认是10,可以上下调',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildDropdownField(
|
||||
label: '返航模式',
|
||||
value: _rthMode,
|
||||
items: ['智能', '预设'],
|
||||
onChanged: (value) => setState(() => _rthMode = value!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 第四行:航线精度 + 断点续飞
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildDropdownField(
|
||||
label: '航线精度',
|
||||
value: _precision,
|
||||
items: ['GPS', 'RTK'],
|
||||
onChanged: (value) => setState(() => _precision = value!),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildDropdownField(
|
||||
label: '断点续飞',
|
||||
value: _breakPointResume,
|
||||
items: ['自动', '手动'],
|
||||
onChanged: (value) =>
|
||||
setState(() => _breakPointResume = value!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 第五行:最低电量 + 任务类型
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
label: '最低电量(%)',
|
||||
controller: _minBatteryController,
|
||||
keyboardType: TextInputType.number,
|
||||
hint: '手动输入',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildDropdownField(
|
||||
label: '任务类型',
|
||||
value: _taskType,
|
||||
items: ['立即'],
|
||||
onChanged: (value) => setState(() => _taskType = value!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 底部按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Color(0xFFE5E6EB)),
|
||||
minimumSize: const Size(80, 36),
|
||||
),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _onCreateTask,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
minimumSize: const Size(100, 36),
|
||||
),
|
||||
child: const Text('确认创建'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
String? hint,
|
||||
bool enabled = true,
|
||||
TextInputType? keyboardType,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF1D2129),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: keyboardType,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(fontSize: 13, color: Color(0xFFC9CDD4)),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFFE5E6EB)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFFE5E6EB)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFF165DFF)),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFFF2F3F5)),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
validator: validator,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdownField({
|
||||
required String label,
|
||||
required String? value,
|
||||
required List<String> items,
|
||||
required void Function(String?) onChanged,
|
||||
String? hint,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF1D2129),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
value: value,
|
||||
hint: hint != null
|
||||
? Text(
|
||||
hint,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFFC9CDD4),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
items: items.map((item) {
|
||||
return DropdownMenuItem(value: item, child: Text(item));
|
||||
}).toList(),
|
||||
onChanged: onChanged,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFFE5E6EB)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFFE5E6EB)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFF165DFF)),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onCreateTask() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// 验证航线是否选择
|
||||
if (_selectedWaylineUuid == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('请选择航线')));
|
||||
return;
|
||||
}
|
||||
|
||||
_submitCreateTask();
|
||||
}
|
||||
}
|
||||
|
||||
// 提交创建任务
|
||||
Future<void> _submitCreateTask() async {
|
||||
try {
|
||||
print('🔍 [CreateTask] 开始创建任务');
|
||||
|
||||
// 构建请求参数
|
||||
final requestData = {
|
||||
'name': _taskNameController.text,
|
||||
'sn': widget.sn,
|
||||
'landing_dock_sn': '', // 降落地场SN(可选)
|
||||
'rth_altitude': int.parse(_rthAltitudeController.text),
|
||||
'rth_mode': _rthMode == '智能' ? 'optimal' : 'straight_line',
|
||||
'wayline_precision_type': _precision.toLowerCase(),
|
||||
'resumable_status': _breakPointResume == '自动' ? 'auto' : 'manual',
|
||||
'min_battery_capacity': int.parse(_minBatteryController.text),
|
||||
'task_type': 'immediate',
|
||||
'repeat_type': 'nonrepeating',
|
||||
'time_zone': 'Asia/Shanghai',
|
||||
'begin_at': null,
|
||||
'end_at': null,
|
||||
'recurring_task_start_time_list': [],
|
||||
'wayline_uuid': _selectedWaylineUuid,
|
||||
};
|
||||
|
||||
print('📦 [CreateTask] 请求参数: $requestData');
|
||||
|
||||
// 调用接口
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.createFlightTask,
|
||||
data: requestData,
|
||||
);
|
||||
|
||||
print('📡 [CreateTask] 响应状态码: ${response.statusCode}');
|
||||
print('📦 [CreateTask] 响应数据: ${response.data}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final Map<String, dynamic> jsonData = (response.data is String)
|
||||
? json.decode(response.data)
|
||||
: Map<String, dynamic>.from(response.data);
|
||||
|
||||
if (jsonData['code'] == 0 || jsonData['code'] == 200) {
|
||||
final taskUuid = jsonData['data']['task_uuid'];
|
||||
print('✅ [CreateTask] 任务创建成功, task_uuid: $taskUuid');
|
||||
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('任务创建成功,任务ID: $taskUuid')));
|
||||
} else {
|
||||
throw Exception(jsonData['message'] ?? '创建任务失败');
|
||||
}
|
||||
} else {
|
||||
throw Exception('网络请求失败');
|
||||
}
|
||||
} catch (e) {
|
||||
print('❌ [CreateTask] 创建任务失败: $e');
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('创建任务失败: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
// 加载航线列表
|
||||
Future<void> _loadWaylines() async {
|
||||
setState(() {
|
||||
_isLoadingWaylines = true;
|
||||
});
|
||||
|
||||
try {
|
||||
print('🔍 [CreateTask] 开始加载航线列表, sn: ${widget.sn}');
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.getWayline,
|
||||
queryParameters: {'sn': widget.sn},
|
||||
);
|
||||
|
||||
print('📡 [CreateTask] 响应状态码: ${response.statusCode}');
|
||||
print('📦 [CreateTask] 响应数据类型: ${response.data.runtimeType}');
|
||||
print('📦 [CreateTask] 响应数据: ${response.data}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// 如果 response.data 是 String,手动解析 JSON
|
||||
final Map<String, dynamic> jsonData = (response.data is String)
|
||||
? json.decode(response.data)
|
||||
: Map<String, dynamic>.from(response.data);
|
||||
|
||||
if (jsonData['code'] == 200 || jsonData['code'] == 0) {
|
||||
final dataField = jsonData['data'];
|
||||
print('🔍 [CreateTask] data 类型: ${dataField.runtimeType}');
|
||||
print('🔍 [CreateTask] data 值: $dataField');
|
||||
|
||||
// data 可能是 List 或包含 list 字段的对象
|
||||
List<dynamic>? waylinesData;
|
||||
|
||||
if (dataField is List) {
|
||||
waylinesData = dataField;
|
||||
} else if (dataField is Map && dataField.containsKey('list')) {
|
||||
waylinesData = dataField['list'];
|
||||
print('🔍 [CreateTask] 从 data.list 获取航线列表');
|
||||
}
|
||||
|
||||
if (waylinesData != null && waylinesData is List) {
|
||||
setState(() {
|
||||
_waylineList = waylinesData!.map((item) {
|
||||
if (item is Map) {
|
||||
return Map<String, dynamic>.from(item);
|
||||
}
|
||||
return {'name': item.toString()};
|
||||
}).toList();
|
||||
});
|
||||
print('✅ [CreateTask] 加载航线列表成功,共 ${_waylineList.length} 条');
|
||||
} else {
|
||||
print('❌ [CreateTask] 未找到航线列表数据');
|
||||
}
|
||||
} else {
|
||||
print(
|
||||
'❌ [CreateTask] 业务异常, code: ${jsonData["code"]}, message: ${jsonData["message"]}',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
print('❌ [CreateTask] HTTP 错误, status: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('❌ [CreateTask] 加载航线列表失败: $e');
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoadingWaylines = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 显示航线选择器
|
||||
void _showWaylineSelector() {
|
||||
if (_waylineList.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('暂无可用航线')));
|
||||
return;
|
||||
}
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'选择航线',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _waylineList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final wayline = _waylineList[index];
|
||||
final waylineName = wayline['name'] ?? '未命名航线';
|
||||
// 兼容多种字段名
|
||||
final waylineUuid =
|
||||
wayline['uuid'] ??
|
||||
wayline['waylineUuid'] ??
|
||||
wayline['id'] ??
|
||||
'';
|
||||
// print('🔍 [CreateTask] 航线数据: $wayline');
|
||||
// print('🔍 [CreateTask] 解析的UUID: $waylineUuid');
|
||||
return ListTile(
|
||||
title: Text(waylineName),
|
||||
subtitle: waylineUuid.isNotEmpty
|
||||
? Text(
|
||||
waylineUuid,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: _selectedWaylineName == waylineName
|
||||
? const Icon(Icons.check, color: Color(0xFF165DFF))
|
||||
: null,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedWaylineName = waylineName;
|
||||
_selectedWaylineUuid = waylineUuid;
|
||||
});
|
||||
print(
|
||||
'✅ [CreateTask] 已选择航线: $_selectedWaylineName, UUID: $_selectedWaylineUuid',
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 可点击的选择字段
|
||||
Widget _buildSelectableField({
|
||||
required String label,
|
||||
required String? value,
|
||||
required String hint,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF1D2129),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFE5E6EB)),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
value ?? hint,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: value != null
|
||||
? const Color(0xFF1D2129)
|
||||
: const Color(0xFFC9CDD4),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down, color: Color(0xFF86909C)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,243 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../../../../../core/consts/http_api_consts.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../domain/entities/flight_task_entity.dart';
|
||||
import '../../domain/entities/flight_task_detail_entity.dart';
|
||||
import '../../domain/usecases/update_flight_task_status_usecase.dart';
|
||||
|
||||
/// 无人机任务与航线控制页面
|
||||
class DroneMissionControlPage extends StatefulWidget {
|
||||
final List<FlightTaskEntity>? selectedTasks;
|
||||
final String? droneSn; // 无人机序列号
|
||||
|
||||
const DroneMissionControlPage({super.key, this.selectedTasks});
|
||||
const DroneMissionControlPage({super.key, this.selectedTasks, this.droneSn});
|
||||
|
||||
@override
|
||||
State<DroneMissionControlPage> createState() => _DroneMissionControlPageState();
|
||||
State<DroneMissionControlPage> createState() =>
|
||||
_DroneMissionControlPageState();
|
||||
}
|
||||
|
||||
class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
FlightTaskEntity? _currentTask;
|
||||
FlightTaskEntity? _listTask; // 列表数据
|
||||
FlightTaskDetailEntity? _detailTask; // 详情数据
|
||||
String? _droneSn; // 无人机序列号
|
||||
bool _isLoading = false;
|
||||
bool _isReturningHome = false; // 是否正在返航
|
||||
bool _isPausing = false; // 是否正在暂停
|
||||
bool _isReturnHomeLoading = false; // 返航命令是否正在执行
|
||||
bool _isPauseLoading = false; // 暂停命令是否正在执行
|
||||
final Dio _dio = Dio();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.selectedTasks != null && widget.selectedTasks!.isNotEmpty) {
|
||||
_currentTask = widget.selectedTasks!.first;
|
||||
_listTask = widget.selectedTasks!.first;
|
||||
_loadTaskDetail();
|
||||
}
|
||||
_droneSn = widget.droneSn; // 初始化无人机序列号
|
||||
}
|
||||
|
||||
Future<void> _loadTaskDetail() async {
|
||||
if (_listTask == null) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
print('🔍 [DroneMissionControl] 开始加载任务详情: ${_listTask!.uuid}');
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.getFlightTaskDetail,
|
||||
queryParameters: {'taskId': _listTask!.uuid},
|
||||
);
|
||||
|
||||
print('📡 [DroneMissionControl] 响应状态码: ${response.statusCode}');
|
||||
print('📦 [DroneMissionControl] 响应数据: ${response.data}');
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
print(
|
||||
'🔍 [DroneMissionControl] responseData 类型: ${responseData.runtimeType}',
|
||||
);
|
||||
|
||||
// 如果 response.data 是 String,手动解析 JSON
|
||||
final Map<String, dynamic> jsonData = (responseData is String)
|
||||
? json.decode(responseData)
|
||||
: Map<String, dynamic>.from(responseData);
|
||||
|
||||
if (jsonData['code'] != 0 && jsonData['code'] != 200) {
|
||||
throw Exception(jsonData['message'] ?? '业务异常');
|
||||
}
|
||||
|
||||
final detailData = jsonData['data'];
|
||||
print('🔍 [DroneMissionControl] data 字段类型: ${detailData.runtimeType}');
|
||||
print('🔍 [DroneMissionControl] data 字段完整内容: $detailData');
|
||||
|
||||
// 确保 data 也是 Map
|
||||
final Map<String, dynamic> detailMap = (detailData is Map)
|
||||
? Map<String, dynamic>.from(detailData)
|
||||
: <String, dynamic>{};
|
||||
|
||||
setState(() {
|
||||
_detailTask = FlightTaskDetailEntity.fromJson(detailMap);
|
||||
_isLoading = false;
|
||||
});
|
||||
// print('✅ [DroneMissionControl] 任务详情加载成功:');
|
||||
// print(' - 返航高度: ${_detailTask!.rthAltitude}');
|
||||
// print(' - 失控动作: ${_detailTask!.outOfControlAction}');
|
||||
// print(' - 返航模式: ${_detailTask!.rthMode}');
|
||||
// print(' - 重复类型: ${_detailTask!.repeatType}');
|
||||
// print(' - 最低电量: ${_detailTask!.minBatteryCapacity}');
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
print('❌ [DroneMissionControl] 加载任务详情失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 执行任务
|
||||
Future<void> _executeTask() 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: 'executing',
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) {
|
||||
print('❌ [DroneMissionControl] 执行任务失败: ${failure.message}');
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('下发指令失败')));
|
||||
},
|
||||
(data) {
|
||||
print('✅ [DroneMissionControl] 任务执行成功: $data');
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('任务执行成功')));
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
print('❌ [DroneMissionControl] 执行任务异常: $e');
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('下发指令失败')));
|
||||
}
|
||||
}
|
||||
|
||||
// 返航/取消返航
|
||||
Future<void> _toggleReturnHome() async {
|
||||
if (_detailTask == null || _detailTask!.sn.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('设备SN不存在')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isReturnHomeLoading = true);
|
||||
|
||||
try {
|
||||
final command = _isReturningHome ? 'return_home_cancel' : 'return_home';
|
||||
print('🔍 [DroneMissionControl] 发送返航命令: $command, deviceSn: $_droneSn');
|
||||
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.flightTaskCommand,
|
||||
data: {'command': command, 'deviceSn': _droneSn},
|
||||
);
|
||||
|
||||
print('📦 [DroneMissionControl] 返航响应: ${response.data}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] == 0) {
|
||||
setState(() => _isReturningHome = !_isReturningHome);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(_isReturningHome ? '已下发返航命令' : '已取消返航')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw Exception(responseData['message'] ?? '操作失败');
|
||||
}
|
||||
} else {
|
||||
throw Exception('请求失败');
|
||||
}
|
||||
} catch (e) {
|
||||
print('❌ [DroneMissionControl] 返航操作失败: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('下发指令失败')));
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isReturnHomeLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
// 暂停/取消暂停
|
||||
Future<void> _togglePause() async {
|
||||
if (_detailTask == null || _detailTask!.sn.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('设备SN不存在')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isPauseLoading = true);
|
||||
|
||||
try {
|
||||
final command = _isPausing ? 'flighttask_recovery' : 'flighttask_pause';
|
||||
print('🔍 [DroneMissionControl] 发送暂停命令: $command, deviceSn: $_droneSn');
|
||||
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.flightTaskCommand,
|
||||
data: {'command': command, 'deviceSn': _droneSn},
|
||||
);
|
||||
|
||||
print('📦 [DroneMissionControl] 暂停响应: ${response.data}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] == 0) {
|
||||
setState(() => _isPausing = !_isPausing);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(_isPausing ? '已下发暂停命令' : '已取消暂停')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw Exception(responseData['message'] ?? '操作失败');
|
||||
}
|
||||
} else {
|
||||
throw Exception('请求失败');
|
||||
}
|
||||
} catch (e) {
|
||||
print('❌ [DroneMissionControl] 暂停操作失败: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('下发指令失败')));
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isPauseLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +290,25 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
}
|
||||
|
||||
Widget _buildMissionInfoCard() {
|
||||
if (_isLoading) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final task = _detailTask;
|
||||
if (task == null) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text('暂无数据', style: TextStyle(color: Color(0xFF86909C))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -96,77 +334,68 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_currentTask != null) ...[
|
||||
_buildInfoRow('任务名称', _currentTask!.name),
|
||||
_buildInfoRow('任务名称', task.name),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoColumn('任务ID', _currentTask!.uuid.substring(0, 8)),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
const SizedBox(width: 100),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusColor(_currentTask!.status).withOpacity(0.1),
|
||||
color: _getStatusColor(task.status).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_currentTask!.status,
|
||||
task.status,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _getStatusColor(_currentTask!.status),
|
||||
color: _getStatusColor(task.status),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('设备序列号', _currentTask!.sn),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('任务类型', _currentTask!.taskType),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('开始时间', _formatDateTime(_currentTask!.beginAt)),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('结束时间', _formatDateTime(_currentTask!.endAt)),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('航点数量', '${_currentTask!.totalWaypoints}'),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('媒体上传', _currentTask!.mediaUploadStatus),
|
||||
] else ...[
|
||||
_buildInfoRow('任务名称', '逆变器区巡检任务'),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoColumn('任务编号', 'UAV-2025052001'),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF165DFF).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'进行中',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF165DFF),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('巡检区域', '逆变器区A区'),
|
||||
_buildInfoRow('网关序列号', task.sn),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('飞行高度', '80 m'),
|
||||
_buildInfoRow('任务类型', task.taskType),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('飞行速度', '8.0 m/s'),
|
||||
_buildInfoRow('航线UUID', task.waylineUuid),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('预计时长', '26 min'),
|
||||
_buildInfoRow('开始时间', _formatDateTime(task.beginAt)),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('电量预估', '68% (可飞行 22 min)'),
|
||||
],
|
||||
_buildInfoRow('结束时间', _formatDateTime(task.endAt)),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('返航高度', '${task.rthAltitude} m'),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow(
|
||||
'失控动作',
|
||||
_getOutOfControlActionText(task.outOfControlAction),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('返航模式', _getRthModeText(task.rthMode)),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('断点续飞状态', task.resumableStatus),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('文件夹ID', '${task.folderId}'),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('期望文件数', '${task.expectedFileCount}'),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('已上传文件数', '${task.uploadedFileCount}'),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('重复类型', _getRepeatTypeText(task.repeatType)),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('间隔', '${task.interval}'),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('每月第几周', '${task.weekOfMonth}'),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('最低电量', '${task.minBatteryCapacity}%'),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -194,6 +423,43 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
}
|
||||
}
|
||||
|
||||
String _getOutOfControlActionText(String action) {
|
||||
switch (action) {
|
||||
case 'return_home':
|
||||
return '返航';
|
||||
case 'hover':
|
||||
return '悬停';
|
||||
case 'land':
|
||||
return '降落';
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
String _getRthModeText(String mode) {
|
||||
switch (mode) {
|
||||
case 'optimal':
|
||||
return '最优路径';
|
||||
case 'straight_line':
|
||||
return '直线返航';
|
||||
default:
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
|
||||
String _getRepeatTypeText(String type) {
|
||||
switch (type) {
|
||||
case 'nonrepeating':
|
||||
return '不重复';
|
||||
case 'daily':
|
||||
return '每日';
|
||||
case 'weekly':
|
||||
return '每周';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildRouteMap() {
|
||||
return Container(
|
||||
height: 200,
|
||||
@@ -255,7 +521,11 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20, color: Color(0xFF86909C)),
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
size: 20,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
onPressed: () {},
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
@@ -279,7 +549,12 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaypointItem(String number, String name, String height, String action) {
|
||||
Widget _buildWaypointItem(
|
||||
String number,
|
||||
String name,
|
||||
String height,
|
||||
String action,
|
||||
) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
@@ -313,25 +588,15 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
),
|
||||
Text(
|
||||
'H: $height',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
action,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF4E5969)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.download_outlined,
|
||||
size: 18,
|
||||
color: Color(0xFF165DFF),
|
||||
),
|
||||
const Icon(Icons.download_outlined, size: 18, color: Color(0xFF165DFF)),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -339,14 +604,12 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
Widget _buildActionButtons() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF5F6F8),
|
||||
),
|
||||
decoration: const BoxDecoration(color: Color(0xFFF5F6F8)),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {},
|
||||
onPressed: _executeTask,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
foregroundColor: Colors.white,
|
||||
@@ -357,18 +620,15 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'开始巡检',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
'执行任务',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {},
|
||||
onPressed: _isPauseLoading ? null : _togglePause,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFF7D00),
|
||||
foregroundColor: Colors.white,
|
||||
@@ -378,9 +638,18 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'暂停任务',
|
||||
style: TextStyle(
|
||||
child: _isPauseLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_isPausing ? '取消暂停' : '暂停任务',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -390,7 +659,7 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {},
|
||||
onPressed: _isReturnHomeLoading ? null : _toggleReturnHome,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF4E5969),
|
||||
side: const BorderSide(color: Color(0xFFC9CDD4)),
|
||||
@@ -399,9 +668,15 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'返航降落',
|
||||
style: TextStyle(
|
||||
child: _isReturnHomeLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(
|
||||
_isReturningHome ? '取消返航' : '返航降落',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -415,22 +690,26 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
|
||||
Widget _buildInfoRow(String label, String value) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF86909C),
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF1D2129),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -442,10 +721,7 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
@@ -455,8 +731,380 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
color: Color(0xFF1D2129),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
softWrap: true,
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 显示创建任务对话框
|
||||
void _showCreateTaskDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
CreateTaskDialog(sn: _listTask?.landingDockSn ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建任务对话框组件
|
||||
class CreateTaskDialog extends StatefulWidget {
|
||||
final String sn;
|
||||
|
||||
const CreateTaskDialog({super.key, required this.sn});
|
||||
|
||||
@override
|
||||
State<CreateTaskDialog> createState() => _CreateTaskDialogState();
|
||||
}
|
||||
|
||||
class _CreateTaskDialogState extends State<CreateTaskDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// 表单字段控制器
|
||||
final TextEditingController _taskNameController = TextEditingController();
|
||||
final TextEditingController _rthAltitudeController = TextEditingController(
|
||||
text: '10',
|
||||
);
|
||||
final TextEditingController _minBatteryController = TextEditingController(
|
||||
text: '50',
|
||||
);
|
||||
|
||||
// 下拉选择值
|
||||
String? _selectedWayline; // 航线
|
||||
String _rthMode = '智能'; // 返航模式:智能/预设
|
||||
String _precision = 'GPS'; // 航线精度:GPS/RTK
|
||||
String _breakPointResume = '自动'; // 断点续飞:自动/手动
|
||||
String _taskType = '立即'; // 任务类型:立即
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_taskNameController.dispose();
|
||||
_rthAltitudeController.dispose();
|
||||
_minBatteryController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width * 0.85,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'创建航线任务',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 第一行:航线名称 + 选择航线
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
label: '航线名称 *',
|
||||
controller: _taskNameController,
|
||||
hint: '请输入',
|
||||
validator: (value) =>
|
||||
value == null || value.isEmpty ? '请输入航线名称' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildDropdownField(
|
||||
label: '选择航线 *',
|
||||
value: _selectedWayline,
|
||||
items: ['航线1', '航线2', '航线3'], // TODO: 从接口获取
|
||||
onChanged: (value) =>
|
||||
setState(() => _selectedWayline = value),
|
||||
hint: '请选择航线',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 第二行:机场SN + 降落地场SN
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
label: '机场SN *',
|
||||
controller: TextEditingController(text: widget.sn),
|
||||
enabled: false,
|
||||
hint: '固定就是当前机场的sn',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
label: '降落地场SN',
|
||||
controller: TextEditingController(),
|
||||
hint: '请输入',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 第三行:返航高度 + 返航模式
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
label: '返航高度(m)',
|
||||
controller: _rthAltitudeController,
|
||||
keyboardType: TextInputType.number,
|
||||
hint: '默认是10,可以上下调',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildDropdownField(
|
||||
label: '返航模式',
|
||||
value: _rthMode,
|
||||
items: ['智能', '预设'],
|
||||
onChanged: (value) => setState(() => _rthMode = value!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 第四行:航线精度 + 断点续飞
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildDropdownField(
|
||||
label: '航线精度',
|
||||
value: _precision,
|
||||
items: ['GPS', 'RTK'],
|
||||
onChanged: (value) =>
|
||||
setState(() => _precision = value!),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildDropdownField(
|
||||
label: '断点续飞',
|
||||
value: _breakPointResume,
|
||||
items: ['自动', '手动'],
|
||||
onChanged: (value) =>
|
||||
setState(() => _breakPointResume = value!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 第五行:最低电量 + 任务类型
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
label: '最低电量(%)',
|
||||
controller: _minBatteryController,
|
||||
keyboardType: TextInputType.number,
|
||||
hint: '手动输入',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildDropdownField(
|
||||
label: '任务类型',
|
||||
value: _taskType,
|
||||
items: ['立即'],
|
||||
onChanged: (value) =>
|
||||
setState(() => _taskType = value!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Color(0xFFE5E6EB)),
|
||||
minimumSize: const Size(80, 36),
|
||||
),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _onCreateTask,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
minimumSize: const Size(100, 36),
|
||||
),
|
||||
child: const Text('确认创建'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
String? hint,
|
||||
bool enabled = true,
|
||||
TextInputType? keyboardType,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF1D2129),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: keyboardType,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(fontSize: 13, color: Color(0xFFC9CDD4)),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFFE5E6EB)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFFE5E6EB)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFF165DFF)),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFFF2F3F5)),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
validator: validator,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdownField({
|
||||
required String label,
|
||||
required String? value,
|
||||
required List<String> items,
|
||||
required void Function(String?) onChanged,
|
||||
String? hint,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF1D2129),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
value: value,
|
||||
hint: hint != null
|
||||
? Text(
|
||||
hint,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFFC9CDD4),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
items: items.map((item) {
|
||||
return DropdownMenuItem(value: item, child: Text(item));
|
||||
}).toList(),
|
||||
onChanged: onChanged,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFFE5E6EB)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFFE5E6EB)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFF165DFF)),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onCreateTask() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// TODO: 调用创建任务接口
|
||||
print('创建任务:');
|
||||
print(' - 航线名称: ${_taskNameController.text}');
|
||||
print(' - 机场SN: ${widget.sn}');
|
||||
print(' - 返航高度: ${_rthAltitudeController.text}');
|
||||
print(' - 返航模式: $_rthMode');
|
||||
print(' - 航线精度: $_precision');
|
||||
print(' - 断点续飞: $_breakPointResume');
|
||||
print(' - 最低电量: ${_minBatteryController.text}');
|
||||
print(' - 任务类型: $_taskType');
|
||||
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('任务创建成功(预留接口)')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ class DroneStationDetailPage extends StatefulWidget {
|
||||
|
||||
class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
late DroneStationBloc _bloc;
|
||||
UAVDetailEntity? _detail; // 无人机详情数据
|
||||
String? _droneSn; // 无人机序列号
|
||||
|
||||
// 悬浮视频监控状态
|
||||
bool showFloatingMonitor = false;
|
||||
@@ -53,6 +55,9 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
Timer? _floatingLoadingTimer;
|
||||
static const _floatingLoadingTimeout = Duration(seconds: 15);
|
||||
|
||||
// 无人机状态轮询计时器
|
||||
Timer? _droneStatusPollingTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -63,6 +68,9 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
deviceSn: widget.station.deviceSn,
|
||||
),
|
||||
);
|
||||
|
||||
// 启动无人机状态轮询(每5秒刷新一次)
|
||||
_startDroneStatusPolling();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -70,9 +78,29 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
_bloc.close();
|
||||
_destroyFloatingRtcEngine();
|
||||
_floatingLoadingTimer?.cancel();
|
||||
_droneStatusPollingTimer?.cancel(); // 停止轮询
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 启动无人机状态轮询
|
||||
void _startDroneStatusPolling() {
|
||||
// 每5秒刷新一次无人机状态
|
||||
_droneStatusPollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
|
||||
if (!mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🔄 定时刷新无人机状态...');
|
||||
_bloc.add(
|
||||
UAVDetailLoad(
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
deviceSn: widget.station.deviceSn,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 销毁悬浮窗的 RTC 引擎
|
||||
void _destroyFloatingRtcEngine() async {
|
||||
// 销毁火山引擎 RTC
|
||||
@@ -202,6 +230,8 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
}
|
||||
|
||||
Widget _buildContent(UAVDetailEntity detail) {
|
||||
_detail = detail; // 保存详情数据供其他方法使用
|
||||
_droneSn = detail.deviceSn; // 保存无人机序列号
|
||||
return Stack(
|
||||
children: [
|
||||
ListView(
|
||||
@@ -371,7 +401,11 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const DroneVideoControlPage(),
|
||||
builder: (context) => DroneVideoControlPage(
|
||||
droneSn: detail.deviceSn,
|
||||
// 使用无人机摄像头列表,如果为null则传空列表
|
||||
cameraList: detail.droneCameraList ?? [],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -667,8 +701,10 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
DroneMissionControlPage(selectedTasks: tasks),
|
||||
builder: (context) => DroneMissionControlPage(
|
||||
selectedTasks: tasks,
|
||||
droneSn: _droneSn,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,290 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:volc_engine_rtc/volc_engine_rtc.dart' as volc;
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../domain/entities/uav_video_stream_entity.dart';
|
||||
import '../../domain/entities/drone_station_entity.dart';
|
||||
import '../bloc/drone_station_bloc.dart';
|
||||
import '../bloc/drone_station_event.dart';
|
||||
import '../bloc/drone_station_state.dart';
|
||||
|
||||
/// 无人机视频回传/远程控制页面
|
||||
class DroneVideoControlPage extends StatefulWidget {
|
||||
const DroneVideoControlPage({super.key});
|
||||
final String droneSn; // 无人机设备序列号
|
||||
final List<CameraInfo>? cameraList; // 摄像头列表
|
||||
|
||||
const DroneVideoControlPage({
|
||||
super.key,
|
||||
required this.droneSn,
|
||||
this.cameraList,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DroneVideoControlPage> createState() => _DroneVideoControlPageState();
|
||||
}
|
||||
|
||||
class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
late DroneStationBloc _bloc;
|
||||
int _selectedTab = 0; // 0: 视频画面, 1: 热成像, 2: 抓拍记录
|
||||
UavVideoStreamEntity? _videoStream;
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
UavLensType? _currentLensType;
|
||||
CameraInfo? _currentCamera; // 当前选中的摄像头
|
||||
|
||||
// 火山引擎 RTC
|
||||
volc.RTCEngine? _rtcEngine;
|
||||
volc.RTCRoom? _rtcRoom;
|
||||
volc.RTCViewContext? _remoteRenderContext;
|
||||
String? _remoteUserId;
|
||||
|
||||
// 事件处理器(注意:使用 I 前缀的接口)
|
||||
final volc.IRTCEngineEventHandler _engineEventHandler = volc.IRTCEngineEventHandler();
|
||||
final volc.IRTCRoomEventHandler _roomEventHandler = volc.IRTCRoomEventHandler();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = sl<DroneStationBloc>();
|
||||
|
||||
// 初始化事件处理器
|
||||
_initVolcEventHandlers();
|
||||
|
||||
// 打印接收到的参数
|
||||
debugPrint('=== 视频控制页面接收参数 ===');
|
||||
debugPrint('droneSn: ${widget.droneSn}');
|
||||
debugPrint('cameraList: ${widget.cameraList}');
|
||||
if (widget.cameraList != null) {
|
||||
for (var camera in widget.cameraList!) {
|
||||
debugPrint(' - cameraIndex: ${camera.cameraIndex}');
|
||||
}
|
||||
}
|
||||
debugPrint('===========================\n');
|
||||
|
||||
// 默认选择第一个摄像头
|
||||
_currentCamera = widget.cameraList?.isNotEmpty == true
|
||||
? widget.cameraList!.first
|
||||
: null;
|
||||
|
||||
debugPrint('默认选择的摄像头: ${_currentCamera?.cameraIndex}');
|
||||
|
||||
// 默认加载广角镜头
|
||||
if (_currentCamera != null) {
|
||||
_loadVideoStream(UavLensType.wide);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_destroyRtcEngine();
|
||||
_bloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 销毁 RTC 引擎
|
||||
Future<void> _destroyRtcEngine() async {
|
||||
if (_rtcRoom != null) {
|
||||
try {
|
||||
await _rtcRoom?.leaveRoom();
|
||||
} catch (_) {}
|
||||
_rtcRoom = null;
|
||||
}
|
||||
if (_rtcEngine != null) {
|
||||
try {
|
||||
_rtcEngine?.destroy(); // 注意:destroy() 返回 void,不能用 await
|
||||
} catch (_) {}
|
||||
_rtcEngine = null;
|
||||
}
|
||||
_remoteRenderContext = null;
|
||||
_remoteUserId = null;
|
||||
}
|
||||
|
||||
/// 加载视频流
|
||||
void _loadVideoStream(UavLensType lensType) {
|
||||
if (_currentCamera == null) {
|
||||
setState(() {
|
||||
_errorMessage = '没有可用的摄像头';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('=== 🎥 镜头切换 ===');
|
||||
debugPrint('当前摄像头: ${_currentCamera!.cameraIndex}');
|
||||
debugPrint('新镜头类型: ${lensType.name} (${_getLensTypeName(lensType)})');
|
||||
debugPrint('旧镜头类型: ${_currentLensType?.name ?? "none"}');
|
||||
debugPrint('==================\n');
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_currentLensType = lensType;
|
||||
});
|
||||
|
||||
_bloc.add(
|
||||
UavVideoStreamLoad(
|
||||
sn: widget.droneSn,
|
||||
cameraIndex: _currentCamera!.cameraIndex,
|
||||
lensType: lensType,
|
||||
qualityType: VideoQualityType.adaptive,
|
||||
videoExpire: 720000000,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 切换摄像头
|
||||
void _switchCamera(CameraInfo camera) {
|
||||
if (camera.cameraIndex == _currentCamera?.cameraIndex) {
|
||||
return; // 如果选择的摄像头与当前相同,不执行操作
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_currentCamera = camera;
|
||||
});
|
||||
|
||||
// 重新加载视频流
|
||||
_loadVideoStream(_currentLensType ?? UavLensType.wide);
|
||||
}
|
||||
|
||||
// 初始化火山引擎事件处理器
|
||||
void _initVolcEventHandlers() {
|
||||
_engineEventHandler.onWarning = (volc.WarningCode code) {
|
||||
debugPrint('⚠️ Volc Warning: $code');
|
||||
};
|
||||
|
||||
_engineEventHandler.onError = (volc.ErrorCode code) {
|
||||
debugPrint('❌ Volc Error: $code');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = '视频错误: $code';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_engineEventHandler.onFirstRemoteVideoFrameDecoded = (
|
||||
String streamId,
|
||||
volc.StreamInfo streamInfo,
|
||||
volc.VideoFrameInfo frameInfo,
|
||||
) {
|
||||
debugPrint('✅✅✅ Volc 第一帧视频解码完成!');
|
||||
debugPrint(' streamId: $streamId, userId: ${streamInfo.userId}');
|
||||
|
||||
if (streamInfo.userId.isNotEmpty && mounted) {
|
||||
setState(() {
|
||||
_remoteUserId = streamInfo.userId;
|
||||
_remoteRenderContext = volc.RTCViewContext.remoteContext(
|
||||
roomId: _videoStream?.roomId ?? '',
|
||||
userId: streamInfo.userId,
|
||||
streamId: streamId,
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_roomEventHandler.onUserPublishStreamVideo = (
|
||||
String userId,
|
||||
volc.StreamInfo streamInfo,
|
||||
bool isPublish,
|
||||
) {
|
||||
debugPrint('📹 Volc 远端用户 $userId 视频流状态: $isPublish');
|
||||
if (isPublish && mounted && _remoteUserId == null) {
|
||||
debugPrint('⏳ 检测到 Volc 视频流推送,等待第一帧解码...');
|
||||
}
|
||||
};
|
||||
|
||||
_roomEventHandler.onUserLeave = (String userId, int reason) {
|
||||
debugPrint('👋 Volc 用户离开: $userId');
|
||||
if (userId == _remoteUserId && mounted) {
|
||||
setState(() {
|
||||
_remoteRenderContext = null;
|
||||
_remoteUserId = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 初始化火山引擎 RTC
|
||||
Future<void> _initRtcEngine(UavVideoStreamEntity stream) async {
|
||||
final appId = stream.appId;
|
||||
final roomId = stream.roomId;
|
||||
final token = stream.token;
|
||||
final userId = stream.userId.isNotEmpty
|
||||
? stream.userId
|
||||
: 'user_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
debugPrint('=== VolcEngine 参数 ===');
|
||||
debugPrint('AppId: "$appId"');
|
||||
debugPrint('RoomId: "$roomId"');
|
||||
debugPrint('UserId: "$userId"');
|
||||
|
||||
if (appId.isEmpty || roomId.isEmpty || token.isEmpty) {
|
||||
setState(() {
|
||||
_errorMessage = 'VolcEngine 参数缺失';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🚀 创建 VolcEngine 引擎...');
|
||||
|
||||
_rtcEngine = await volc.RTCEngine.createRTCEngine(
|
||||
volc.RTCVideoContext(appId: appId, eventHandler: _engineEventHandler),
|
||||
);
|
||||
|
||||
if (_rtcEngine == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'VolcEngine 引擎创建失败';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
debugPrint('✅ VolcEngine 引擎创建成功');
|
||||
|
||||
_rtcRoom = await _rtcEngine?.createRTCRoom(roomId);
|
||||
if (_rtcRoom == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'VolcEngine 房间创建失败';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
debugPrint('✅ VolcEngine 房间创建成功');
|
||||
|
||||
await _rtcRoom?.setRTCRoomEventHandler(_roomEventHandler);
|
||||
|
||||
debugPrint('🔑 加入 VolcEngine 房间...');
|
||||
|
||||
await _rtcRoom?.joinRoom(
|
||||
token: token,
|
||||
userInfo: volc.UserInfo(userId: userId, extraInfo: ''),
|
||||
userVisibility: true,
|
||||
roomConfig: volc.RoomConfig(
|
||||
isPublishAudio: false,
|
||||
isPublishVideo: false,
|
||||
isAutoSubscribeAudio: false,
|
||||
isAutoSubscribeVideo: true,
|
||||
),
|
||||
);
|
||||
|
||||
debugPrint('✅ 成功加入 VolcEngine 房间: $roomId');
|
||||
debugPrint('⏳ 等待视频流推送...');
|
||||
} catch (e) {
|
||||
debugPrint('❌ VolcEngine 初始化失败: $e');
|
||||
setState(() {
|
||||
_errorMessage = 'VolcEngine 加入失败: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
return BlocProvider.value(
|
||||
value: _bloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F6F8),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
@@ -22,15 +293,53 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2129)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'无人机视频回传 / 远程控制',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
if (widget.cameraList != null && widget.cameraList!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<CameraInfo>(
|
||||
value: _currentCamera,
|
||||
hint: const Text(
|
||||
'选择摄像头',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFF86909C)),
|
||||
),
|
||||
items: widget.cameraList!.map((camera) {
|
||||
return DropdownMenuItem<CameraInfo>(
|
||||
value: camera,
|
||||
child: Text(
|
||||
camera.cameraIndex,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (CameraInfo? newCamera) {
|
||||
if (newCamera != null) {
|
||||
_switchCamera(newCamera);
|
||||
}
|
||||
},
|
||||
isDense: true,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF165DFF),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings, color: Color(0xFF1D2129)),
|
||||
@@ -38,38 +347,70 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
body: BlocConsumer<DroneStationBloc, DroneStationState>(
|
||||
listener: (context, state) {
|
||||
if (state is UavVideoStreamLoaded) {
|
||||
setState(() {
|
||||
_videoStream = state.videoStream;
|
||||
});
|
||||
debugPrint('=== 视频流加载成功 ===');
|
||||
debugPrint('URL Type: ${state.videoStream.urlType}');
|
||||
|
||||
if (state.videoStream.urlType == 'volc') {
|
||||
_destroyRtcEngine();
|
||||
_initRtcEngine(state.videoStream);
|
||||
} else {
|
||||
setState(() {
|
||||
_errorMessage = '不支持的 URL 类型: ${state.videoStream.urlType}';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} else if (state is UavVideoStreamError) {
|
||||
setState(() {
|
||||
_errorMessage = state.message;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
// Tab切换
|
||||
_buildTabBar(),
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// 视频画面
|
||||
_buildVideoPlayer(),
|
||||
const SizedBox(height: 12),
|
||||
// 飞行数据
|
||||
_buildFlightData(),
|
||||
const SizedBox(height: 12),
|
||||
// AI识别结果
|
||||
_buildAIResults(),
|
||||
const SizedBox(height: 12),
|
||||
// 地图和摇杆
|
||||
_buildMapAndJoystick(),
|
||||
const SizedBox(height: 16),
|
||||
// 底部工具栏
|
||||
_buildBottomToolbar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Tab切换栏
|
||||
String _getLensTypeName(UavLensType lensType) {
|
||||
switch (lensType) {
|
||||
case UavLensType.wide:
|
||||
return '广角';
|
||||
case UavLensType.zoom:
|
||||
return '变焦';
|
||||
case UavLensType.ir:
|
||||
return '红外';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTabBar() {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
@@ -115,7 +456,6 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 视频播放器
|
||||
Widget _buildVideoPlayer() {
|
||||
return Stack(
|
||||
children: [
|
||||
@@ -127,76 +467,122 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.asset(
|
||||
'assets/images/xunjian.png',
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
child: _isLoading
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'正在加载视频流...',
|
||||
style: TextStyle(fontSize: 14, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _errorMessage != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.white, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => _loadVideoStream(_currentLensType ?? UavLensType.wide),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF165DFF)),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _videoStream != null && _remoteRenderContext != null
|
||||
? volc.RTCSurfaceView(
|
||||
context: _remoteRenderContext!,
|
||||
renderMode: volc.VideoRenderMode.fit,
|
||||
)
|
||||
: _videoStream != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.videocam_off, size: 48, color: Colors.grey),
|
||||
const SizedBox(height: 12),
|
||||
const Text('等待视频流推送...', style: TextStyle(fontSize: 14, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Image.asset('assets/images/xunjian.png', fit: BoxFit.cover, width: double.infinity),
|
||||
),
|
||||
),
|
||||
// 录制标识
|
||||
Positioned(
|
||||
top: 12,
|
||||
left: 12,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF53F3F),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Container(width: 8, height: 8, decoration: const BoxDecoration(color: Color(0xFFF53F3F), shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'REC 00:12:36',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Text('REC 00:12:36', style: TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 缩放标识
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
child: Container(
|
||||
child: PopupMenuButton<UavLensType>(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'1.0x',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_getLensTypeName(_currentLensType ?? UavLensType.wide), style: const TextStyle(fontSize: 12, color: Colors.white)),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.arrow_drop_down, color: Colors.white, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
onSelected: (UavLensType lensType) {
|
||||
debugPrint('👆 用户点击了镜头切换: ${lensType.name}');
|
||||
if (lensType != _currentLensType) {
|
||||
debugPrint('✅ 镜头类型不同,开始加载...');
|
||||
_loadVideoStream(lensType);
|
||||
} else {
|
||||
debugPrint('⚠️ 镜头类型相同,忽略操作');
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) => <PopupMenuEntry<UavLensType>>[
|
||||
const PopupMenuItem<UavLensType>(value: UavLensType.wide, child: Text('广角')),
|
||||
const PopupMenuItem<UavLensType>(value: UavLensType.zoom, child: Text('变焦')),
|
||||
const PopupMenuItem<UavLensType>(value: UavLensType.ir, child: Text('红外')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 飞行数据
|
||||
Widget _buildFlightData() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
boxShadow: const [BoxShadow(color: Color(0x0D000000), blurRadius: 8, offset: Offset(0, 2))],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -217,53 +603,26 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF86909C))),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Color(0xFF1D2129))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// AI识别结果
|
||||
Widget _buildAIResults() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
boxShadow: const [BoxShadow(color: Color(0x0D000000), blurRadius: 8, offset: Offset(0, 2))],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'AI 识别结果',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const Text('AI 识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1D2129))),
|
||||
const SizedBox(height: 12),
|
||||
_buildAIResultItem('热斑疑似', '3 处'),
|
||||
const SizedBox(height: 8),
|
||||
@@ -278,128 +637,51 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
onTap: () {},
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Color(0xFFFF7D00),
|
||||
size: 20,
|
||||
),
|
||||
const Icon(Icons.warning_amber_rounded, color: Color(0xFFFF7D00), size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
count,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)))),
|
||||
Text(count, style: const TextStyle(fontSize: 14, color: Color(0xFF4E5969))),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: Color(0xFF86909C),
|
||||
size: 20,
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: Color(0xFF86909C), size: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 地图和摇杆
|
||||
Widget _buildMapAndJoystick() {
|
||||
return Row(
|
||||
children: [
|
||||
// 地图
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 160,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
boxShadow: const [BoxShadow(color: Color(0x0D000000), blurRadius: 8, offset: Offset(0, 2))],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.asset(
|
||||
'assets/images/xunjian.png',
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
),
|
||||
child: Image.asset('assets/images/xunjian.png', fit: BoxFit.cover, width: double.infinity),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 摇杆控制器
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 160,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
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: () {},
|
||||
),
|
||||
),
|
||||
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: () {})),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -408,20 +690,13 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 底部工具栏
|
||||
Widget _buildBottomToolbar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
boxShadow: const [BoxShadow(color: Color(0x0D000000), blurRadius: 8, offset: Offset(0, 2))],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
@@ -442,15 +717,10 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
children: [
|
||||
Icon(icon, size: 28, color: const Color(0xFF4E5969)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF4E5969))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../domain/entities/uav_video_stream_entity.dart';
|
||||
import '../bloc/drone_station_bloc.dart';
|
||||
import '../bloc/drone_station_event.dart';
|
||||
import '../bloc/drone_station_state.dart';
|
||||
|
||||
/// 无人机实时视频监控页面(使用新的 changeLens 接口)
|
||||
class UavLiveVideoPage extends StatefulWidget {
|
||||
final String droneSn; // 无人机设备序列号
|
||||
final String cameraIndex; // 摄像头编号
|
||||
|
||||
const UavLiveVideoPage({
|
||||
super.key,
|
||||
required this.droneSn,
|
||||
required this.cameraIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UavLiveVideoPage> createState() => _UavLiveVideoPageState();
|
||||
}
|
||||
|
||||
class _UavLiveVideoPageState extends State<UavLiveVideoPage> {
|
||||
late DroneStationBloc _bloc;
|
||||
UavVideoStreamEntity? _videoStream;
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
UavLensType? _currentLensType;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = sl<DroneStationBloc>();
|
||||
// 默认加载广角镜头
|
||||
_loadVideoStream(UavLensType.wide);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 加载视频流
|
||||
void _loadVideoStream(UavLensType lensType) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_currentLensType = lensType;
|
||||
});
|
||||
|
||||
_bloc.add(
|
||||
UavVideoStreamLoad(
|
||||
sn: widget.droneSn,
|
||||
cameraIndex: widget.cameraIndex,
|
||||
lensType: lensType,
|
||||
qualityType: VideoQualityType.adaptive,
|
||||
videoExpire: 720000000,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _bloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'无人机实时视频',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
// 镜头切换按钮
|
||||
PopupMenuButton<UavLensType>(
|
||||
icon: const Icon(Icons.videocam, color: Colors.white),
|
||||
tooltip: '切换镜头',
|
||||
onSelected: (lensType) {
|
||||
_loadVideoStream(lensType);
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: UavLensType.wide,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.videocam, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('广角镜头'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: UavLensType.zoom,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.zoom_in, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('变焦镜头'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: UavLensType.ir,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.thermostat, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('红外镜头'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: BlocConsumer<DroneStationBloc, DroneStationState>(
|
||||
listener: (context, state) {
|
||||
if (state is UavVideoStreamLoaded) {
|
||||
setState(() {
|
||||
_videoStream = state.videoStream;
|
||||
_isLoading = false;
|
||||
});
|
||||
debugPrint('=== 视频流加载成功 ===');
|
||||
debugPrint('URL Type: ${state.videoStream.urlType}');
|
||||
debugPrint('AppId: ${state.videoStream.appId}');
|
||||
debugPrint('RoomId: ${state.videoStream.roomId}');
|
||||
debugPrint('UserId: ${state.videoStream.userId}');
|
||||
// TODO: 这里可以初始化 RTC 引擎并显示视频
|
||||
} else if (state is UavVideoStreamError) {
|
||||
setState(() {
|
||||
_errorMessage = state.message;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (_isLoading) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(color: Colors.white),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'正在加载视频流...',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (_currentLensType != null) {
|
||||
_loadVideoStream(_currentLensType!);
|
||||
}
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_videoStream == null) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'暂无视频信号',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: 这里根据 _videoStream 的 urlType 初始化对应的 RTC 引擎
|
||||
// - 如果是 volc,使用火山引擎 RTC SDK
|
||||
// - 如果是 agora,使用声网 RTC SDK
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.videocam_off,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'视频流已获取\nURL Type: ${_videoStream!.urlType}\nCamera: $_currentLensType',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'请集成 RTC SDK 后在此处显示视频画面',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import '../pages/drone_mission_control_page.dart';
|
||||
|
||||
/// 无人机机场与设备状态组件
|
||||
class DroneStationStatusWidget extends StatelessWidget {
|
||||
const DroneStationStatusWidget({super.key});
|
||||
final String? droneSn; // 无人机序列号
|
||||
|
||||
const DroneStationStatusWidget({super.key, this.droneSn});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -220,7 +222,8 @@ class DroneStationStatusWidget extends StatelessWidget {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const DroneMissionControlPage(),
|
||||
builder: (context) =>
|
||||
DroneMissionControlPage(droneSn: droneSn),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -252,19 +255,12 @@ class DroneStationStatusWidget extends StatelessWidget {
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
),
|
||||
child: Icon(icon, color: color, size: 24),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF4E5969)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -277,10 +273,7 @@ class DroneStationStatusWidget extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
@@ -314,15 +307,16 @@ class DroneStationStatusWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 带进度条的状态行
|
||||
Widget _buildStatusRowWithProgress(String label, String value, String progress) {
|
||||
Widget _buildStatusRowWithProgress(
|
||||
String label,
|
||||
String value,
|
||||
String progress,
|
||||
) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
@@ -343,11 +337,7 @@ class DroneStationStatusWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 12,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
const Icon(Icons.arrow_forward_ios, size: 12, color: Color(0xFF86909C)),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -358,10 +348,7 @@ class DroneStationStatusWidget extends StatelessWidget {
|
||||
children: [
|
||||
const Text(
|
||||
'气象条件',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFF86909C)),
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
@@ -379,18 +366,12 @@ class DroneStationStatusWidget extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'25°C',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFF1D2129)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'东南风 2级',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFF1D2129)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -404,10 +385,7 @@ class DroneStationStatusWidget extends StatelessWidget {
|
||||
children: [
|
||||
const Text(
|
||||
'电量',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFF86909C)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
@@ -416,7 +394,9 @@ class DroneStationStatusWidget extends StatelessWidget {
|
||||
child: LinearProgressIndicator(
|
||||
value: 0.78,
|
||||
backgroundColor: const Color(0xFFF2F3F5),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF00B42A)),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
Color(0xFF00B42A),
|
||||
),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../../../core/consts/http_api_consts.dart';
|
||||
import '../../domain/entities/flight_task_entity.dart';
|
||||
import '../pages/create_task_page.dart';
|
||||
|
||||
class FlightTaskSelectorModal extends StatefulWidget {
|
||||
final String currentGatewaySn;
|
||||
@@ -40,18 +41,51 @@ class _FlightTaskSelectorModalState extends State<FlightTaskSelectorModal> {
|
||||
}
|
||||
|
||||
Future<void> _selectDateRange(BuildContext context) async {
|
||||
final picked = await showDateRangePicker(
|
||||
// 先选择日期范围
|
||||
final pickedDate = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
initialDateRange: _selectedDateRange,
|
||||
);
|
||||
if (picked != null) {
|
||||
|
||||
if (pickedDate != null) {
|
||||
// 选择开始时间(时分秒)
|
||||
final startTime = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(pickedDate.start),
|
||||
);
|
||||
|
||||
if (startTime != null) {
|
||||
// 选择结束时间(时分秒)
|
||||
final endTime = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: const TimeOfDay(hour: 23, minute: 59),
|
||||
);
|
||||
|
||||
if (endTime != null) {
|
||||
setState(() {
|
||||
_selectedDateRange = picked;
|
||||
_selectedDateRange = DateTimeRange(
|
||||
start: DateTime(
|
||||
pickedDate.start.year,
|
||||
pickedDate.start.month,
|
||||
pickedDate.start.day,
|
||||
startTime.hour,
|
||||
startTime.minute,
|
||||
),
|
||||
end: DateTime(
|
||||
pickedDate.end.year,
|
||||
pickedDate.end.month,
|
||||
pickedDate.end.day,
|
||||
endTime.hour,
|
||||
endTime.minute,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTasks() async {
|
||||
if (_selectedDateRange == null || _selectedDeviceSn == null) {
|
||||
@@ -95,6 +129,7 @@ class _FlightTaskSelectorModalState extends State<FlightTaskSelectorModal> {
|
||||
data.forEach((sn, value) {
|
||||
if (value != null && value['list'] != null) {
|
||||
final list = value['list'] as List<dynamic>;
|
||||
print(' [FlightTask] API返回的原始数据: $list');
|
||||
result[sn] = list.map((item) => FlightTaskEntity.fromJson(item)).toList();
|
||||
} else {
|
||||
result[sn] = null;
|
||||
@@ -129,7 +164,10 @@ class _FlightTaskSelectorModalState extends State<FlightTaskSelectorModal> {
|
||||
const SizedBox(height: 12),
|
||||
_buildDeviceSelector(),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _loadTasks,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
@@ -139,6 +177,18 @@ class _FlightTaskSelectorModalState extends State<FlightTaskSelectorModal> {
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text('查询任务'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _showCreateTaskDialog,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF00B42A),
|
||||
minimumSize: const Size(48, 48),
|
||||
),
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
@@ -173,7 +223,7 @@ class _FlightTaskSelectorModalState extends State<FlightTaskSelectorModal> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
_selectedDateRange != null
|
||||
? '${_formatDate(_selectedDateRange!.start)} - ${_formatDate(_selectedDateRange!.end)}'
|
||||
? '${_formatDateTimeFull(_selectedDateRange!.start)} -\n${_formatDateTimeFull(_selectedDateRange!.end)}'
|
||||
: '选择时间范围',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
@@ -307,6 +357,11 @@ class _FlightTaskSelectorModalState extends State<FlightTaskSelectorModal> {
|
||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _formatDateTimeFull(DateTime dateTime) {
|
||||
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} '
|
||||
'${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _formatDateTime(String dateTimeStr) {
|
||||
try {
|
||||
final dateTime = DateTime.parse(dateTimeStr);
|
||||
@@ -315,4 +370,15 @@ class _FlightTaskSelectorModalState extends State<FlightTaskSelectorModal> {
|
||||
return dateTimeStr;
|
||||
}
|
||||
}
|
||||
|
||||
// 显示创建任务对话框
|
||||
void _showCreateTaskDialog() {
|
||||
Navigator.pop(context); // 先关闭当前模态页
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CreateTaskPage(sn: widget.currentGatewaySn),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user