From 8eb878dd816c8c757149ff54acb51c5cdbb01f86 Mon Sep 17 00:00:00 2001 From: Songzex <2402265378@qq.com> Date: Thu, 11 Jun 2026 17:30:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=97=A0=E4=BA=BA=E6=9C=BA=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=AF=B9=E6=8E=A5+=E6=94=AF=E6=8C=81=E6=97=A0=E4=BA=BA?= =?UTF-8?q?=E6=9C=BA=E6=9C=AA=E5=9C=A8=E7=BA=BF=E6=97=B6=E5=80=995S?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E4=B8=80=E4=B8=8B=E6=9F=A5=E8=AF=A2=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E3=80=82=E4=BE=BF=E4=BA=8E=E8=A7=86=E9=A2=91=E6=AD=A3?= =?UTF-8?q?=E5=B8=B8=E5=B1=95=E7=A4=BA=E3=80=82=20=E6=97=A0=E4=BA=BA?= =?UTF-8?q?=E6=9C=BA=E8=A7=86=E9=A2=91=E7=9A=84=E6=91=84=E5=83=8F=E5=A4=B4?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=8A=9F=E8=83=BD=E5=92=8C=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=8A=9F=E8=83=BD=EF=BC=88=E5=B9=BF=E8=A7=92?= =?UTF-8?q?=E5=92=8C=E7=BA=A2=E5=A4=96=20=E5=92=8C=E7=AD=89=EF=BC=89=20?= =?UTF-8?q?=E6=97=A0=E4=BA=BA=E6=9C=BA=E8=88=AA=E7=BA=BF=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E9=A1=B5=E9=9D=A2=E7=9A=84=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E5=92=8C=E6=8E=A5=E5=8F=A3=E5=AF=B9=E6=8E=A5=20=E6=97=A0?= =?UTF-8?q?=E4=BA=BA=E6=9C=BA=E8=88=AA=E7=BA=BF=E4=BB=BB=E5=8A=A1=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E6=8E=A5=E5=8F=A3=EF=BC=88=E6=94=AF=E6=8C=81=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E8=BF=87=E6=BB=A4=EF=BC=89=20=E6=97=A0=E4=BA=BA?= =?UTF-8?q?=E6=9C=BA=E6=89=A7=E8=A1=8C=E8=88=AA=E7=BA=BF=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=BC=80=E5=8F=91=E5=92=8C=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=AF=B9=E6=8E=A5=20=E6=97=A0=E4=BA=BA=E6=9C=BA=E6=9A=82?= =?UTF-8?q?=E5=81=9C=E6=89=A7=E8=A1=8C=E4=BB=BB=E5=8A=A1=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E5=92=8C=E6=8E=A5=E5=8F=A3=E5=AF=B9=E6=8E=A5?= =?UTF-8?q?=20=E6=97=A0=E4=BA=BA=E6=9C=BA=E8=BF=94=E8=88=AA=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=92=8C=E5=8F=96=E6=B6=88=E8=BF=94=E8=88=AA=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=BC=80=E5=8F=91=E5=92=8C=E6=8E=A5=E5=8F=A3=E5=AF=B9?= =?UTF-8?q?=E6=8E=A5=20=E6=97=A0=E4=BA=BA=E6=9C=BA=E6=9C=BA=E5=9C=BA?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E4=B8=AD=E7=9A=84=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E7=9A=84=E6=9B=B4=E6=AD=A3=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/consts/http_api_consts.dart | 18 + lib/core/di/injection.dart | 10 +- .../datasources/drone_station_datasource.dart | 19 +- .../drone_station_datasource_impl.dart | 198 +++- .../drone_station_repository_impl.dart | 68 ++ .../domain/entities/drone_station_entity.dart | 35 +- .../entities/flight_task_detail_entity.dart | 121 +++ .../domain/entities/flight_task_entity.dart | 15 +- .../entities/uav_video_stream_entity.dart | 93 ++ .../drone_station_repository.dart | 21 + .../get_uav_video_stream_usecase.dart | 27 + .../update_flight_task_status_usecase.dart | 19 + .../presentation/bloc/drone_station_bloc.dart | 29 + .../bloc/drone_station_event.dart | 21 + .../bloc/drone_station_state.dart | 32 + .../presentation/pages/create_task_page.dart | 627 ++++++++++++ .../pages/drone_mission_control_page.dart | 890 +++++++++++++++--- .../pages/drone_station_detail_page.dart | 42 +- .../pages/drone_video_control_page.dart | 736 ++++++++++----- .../pages/uav_live_video_page.dart | 240 +++++ .../widgets/drone_station_status_widget.dart | 64 +- .../widgets/flight_task_selector_modal.dart | 98 +- 22 files changed, 2991 insertions(+), 432 deletions(-) create mode 100644 lib/features/v2/device_list/domain/entities/flight_task_detail_entity.dart create mode 100644 lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart create mode 100644 lib/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart create mode 100644 lib/features/v2/device_list/domain/usecases/update_flight_task_status_usecase.dart create mode 100644 lib/features/v2/device_list/presentation/pages/create_task_page.dart create mode 100644 lib/features/v2/device_list/presentation/pages/uav_live_video_page.dart diff --git a/lib/core/consts/http_api_consts.dart b/lib/core/consts/http_api_consts.dart index 494ca4ba..2b75ad7d 100644 --- a/lib/core/consts/http_api_consts.dart +++ b/lib/core/consts/http_api_consts.dart @@ -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"; } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index db39af61..301d34e9 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -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 init() async { sl.registerLazySingleton( () => GetVideoStreamUseCase(sl()), ); + sl.registerLazySingleton( + () => GetUavVideoStreamUseCase(sl()), + ); + sl.registerLazySingleton( + () => UpdateFlightTaskStatusUseCase(sl()), + ); sl.registerFactory( - () => DroneStationBloc(sl(), sl(), sl()), + () => DroneStationBloc(sl(), sl(), sl(), sl()), ); /// Robot List V2 diff --git a/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart b/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart index bf93691a..6419f528 100644 --- a/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart +++ b/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart @@ -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> getDroneStationList(int siteId); @@ -17,4 +18,20 @@ abstract class DroneStationDataSource { required int beginAt, required int endAt, }); -} \ No newline at end of file + + Future getFlightTaskDetail(String taskId); + + Future> updateFlightTaskStatus({ + required String taskId, + required String status, + }); + + /// 切换无人机镜头获取实时视频流 + Future getUavVideoStream({ + required String sn, + required String cameraIndex, + UavLensType? lensType, + VideoQualityType qualityType = VideoQualityType.adaptive, + int videoExpire = 720000000, + }); +} diff --git a/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart b/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart index 004273a4..b319a9b8 100644 --- a/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart +++ b/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart @@ -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; } -} \ No newline at end of file + + @override + Future 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> 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 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) { + 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'; + } + } +} diff --git a/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart b/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart index 20f4479d..7a804efb 100644 --- a/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart +++ b/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart @@ -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?>>> getFlightTasks({ + required List 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> getFlightTaskDetail(String taskId) async { + try { + final detail = await dataSource.getFlightTaskDetail(taskId); + return Right(detail); + } catch (e) { + return Left(Failure(e.toString())); + } + } + + @override + Future>> 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> 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())); + } + } } \ No newline at end of file diff --git a/lib/features/v2/device_list/domain/entities/drone_station_entity.dart b/lib/features/v2/device_list/domain/entities/drone_station_entity.dart index 7d3c048c..53c24437 100644 --- a/lib/features/v2/device_list/domain/entities/drone_station_entity.dart +++ b/lib/features/v2/device_list/domain/entities/drone_station_entity.dart @@ -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? gatewayCameraList; - final List? droneCameraList; + final List? droneCameraList; final int? orgId; final int? siteId; final int? userId; @@ -126,7 +126,12 @@ class UAVDetailEntity extends Equatable { factory UAVDetailEntity.fromJson(Map 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)) + .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)) + .toList() + : null, orgId: json['orgId'] ?? 0, siteId: json['siteId'] ?? 0, userId: json['userId'], @@ -387,4 +400,4 @@ class DroneStationEntity extends Equatable { siteId, userId, ]; -} \ No newline at end of file +} diff --git a/lib/features/v2/device_list/domain/entities/flight_task_detail_entity.dart b/lib/features/v2/device_list/domain/entities/flight_task_detail_entity.dart new file mode 100644 index 00000000..ebb7fdc7 --- /dev/null +++ b/lib/features/v2/device_list/domain/entities/flight_task_detail_entity.dart @@ -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 json) { + // 安全处理 folder_info 嵌套结构 + final rawFolderInfo = json['folder_info']; + final Map folderInfo = (rawFolderInfo is Map) + ? Map.from(rawFolderInfo) + : {}; + 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 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, + }; + } +} diff --git a/lib/features/v2/device_list/domain/entities/flight_task_entity.dart b/lib/features/v2/device_list/domain/entities/flight_task_entity.dart index 749ff3c8..2f587b6e 100644 --- a/lib/features/v2/device_list/domain/entities/flight_task_entity.dart +++ b/lib/features/v2/device_list/domain/entities/flight_task_entity.dart @@ -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 toJson() { return { 'name': name, diff --git a/lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart b/lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart new file mode 100644 index 00000000..a5170484 --- /dev/null +++ b/lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart @@ -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 json) { + return UavVideoStreamEntity( + sn: json['sn'] ?? '', + cameraIndex: json['camera_index'] ?? '', + url: json['url'] ?? '', + expireTs: json['expire_ts'] ?? 0, + urlType: json['url_type'] ?? 'volc', + ); + } + + Map toMap() { + return { + 'sn': sn, + 'cameraIndex': cameraIndex, + 'url': url, + 'expireTs': expireTs.toString(), + 'urlType': urlType, + }; + } + + /// 解析RTC参数(从URL中提取) + Map parseRtcParams() { + final params = {}; + 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 get props => [sn, cameraIndex, url, expireTs, urlType]; +} diff --git a/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart b/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart index ed768bd4..34cdb349 100644 --- a/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart +++ b/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart @@ -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>> getDroneStationList( @@ -16,4 +18,23 @@ abstract class DroneStationRepository { required String cameraIndex, required String cameraPosition, }); + Future?>>> getFlightTasks({ + required List sns, + required int beginAt, + required int endAt, + }); + Future> getFlightTaskDetail(String taskId); + Future>> updateFlightTaskStatus({ + required String taskId, + required String status, + }); + + /// 获取无人机实时视频流 + Future> getUavVideoStream({ + required String sn, + required String cameraIndex, + UavLensType? lensType, + VideoQualityType qualityType, + int videoExpire, + }); } \ No newline at end of file diff --git a/lib/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart b/lib/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart new file mode 100644 index 00000000..37ffb1aa --- /dev/null +++ b/lib/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart @@ -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> 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, + ); + } +} diff --git a/lib/features/v2/device_list/domain/usecases/update_flight_task_status_usecase.dart b/lib/features/v2/device_list/domain/usecases/update_flight_task_status_usecase.dart new file mode 100644 index 00000000..94b68ab3 --- /dev/null +++ b/lib/features/v2/device_list/domain/usecases/update_flight_task_status_usecase.dart @@ -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>> execute({ + required String taskId, + required String status, + }) async { + return await repository.updateFlightTaskStatus( + taskId: taskId, + status: status, + ); + } +} diff --git a/lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart b/lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart index 7a01abfa..9957bc51 100644 --- a/lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart +++ b/lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart @@ -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 { 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(_onLoadData); on(_onRefresh); on(_onUAVDetailLoad); on(_onVideoStreamLoad); + on(_onUavVideoStreamLoad); } Future _onLoadData( @@ -79,4 +83,29 @@ class DroneStationBloc extends Bloc { (videoStream) => emit(VideoStreamLoaded(videoStream, event.cameraPosition)), ); } + + /// 处理无人机实时视频流加载事件 + Future _onUavVideoStreamLoad( + UavVideoStreamLoad event, + Emitter 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, + )), + ); + } } diff --git a/lib/features/v2/device_list/presentation/bloc/drone_station_event.dart b/lib/features/v2/device_list/presentation/bloc/drone_station_event.dart index 159c5dc1..aedc25bd 100644 --- a/lib/features/v2/device_list/presentation/bloc/drone_station_event.dart +++ b/lib/features/v2/device_list/presentation/bloc/drone_station_event.dart @@ -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 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 get props => [sn, cameraIndex, lensType, qualityType, videoExpire]; +} diff --git a/lib/features/v2/device_list/presentation/bloc/drone_station_state.dart b/lib/features/v2/device_list/presentation/bloc/drone_station_state.dart index 1b42422a..10b6f2f9 100644 --- a/lib/features/v2/device_list/presentation/bloc/drone_station_state.dart +++ b/lib/features/v2/device_list/presentation/bloc/drone_station_state.dart @@ -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(); @@ -76,6 +77,37 @@ class VideoStreamError extends DroneStationState { const VideoStreamError(this.message); + @override + List 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 get props => [videoStream, cameraIndex, lensType]; +} + +/// 无人机实时视频流加载失败状态 +class UavVideoStreamError extends DroneStationState { + final String message; + + const UavVideoStreamError(this.message); + @override List get props => [message]; } \ No newline at end of file diff --git a/lib/features/v2/device_list/presentation/pages/create_task_page.dart b/lib/features/v2/device_list/presentation/pages/create_task_page.dart new file mode 100644 index 00000000..cdc14b78 --- /dev/null +++ b/lib/features/v2/device_list/presentation/pages/create_task_page.dart @@ -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 createState() => _CreateTaskPageState(); +} + +class _CreateTaskPageState extends State { + final _formKey = GlobalKey(); + + // 表单字段控制器 + 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> _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 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( + 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 _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 jsonData = (response.data is String) + ? json.decode(response.data) + : Map.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 _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 jsonData = (response.data is String) + ? json.decode(response.data) + : Map.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? 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.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)), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/v2/device_list/presentation/pages/drone_mission_control_page.dart b/lib/features/v2/device_list/presentation/pages/drone_mission_control_page.dart index 2075f47b..bfb41fe5 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_mission_control_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_mission_control_page.dart @@ -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? selectedTasks; + final String? droneSn; // 无人机序列号 - const DroneMissionControlPage({super.key, this.selectedTasks}); + const DroneMissionControlPage({super.key, this.selectedTasks, this.droneSn}); @override - State createState() => _DroneMissionControlPageState(); + State createState() => + _DroneMissionControlPageState(); } class _DroneMissionControlPageState extends State { - 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 _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 jsonData = (responseData is String) + ? json.decode(responseData) + : Map.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 detailMap = (detailData is Map) + ? Map.from(detailData) + : {}; + + 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 _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(); + 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 _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 _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 { } 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 { ), ), const SizedBox(height: 16), - if (_currentTask != null) ...[ - _buildInfoRow('任务名称', _currentTask!.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), + _buildInfoRow('任务名称', task.name), + const SizedBox(height: 12), + Row( + children: [ + 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区'), - const SizedBox(height: 12), - _buildInfoRow('飞行高度', '80 m'), - const SizedBox(height: 12), - _buildInfoRow('飞行速度', '8.0 m/s'), - const SizedBox(height: 12), - _buildInfoRow('预计时长', '26 min'), - const SizedBox(height: 12), - _buildInfoRow('电量预估', '68% (可飞行 22 min)'), - ], + ), + ], + ), + const SizedBox(height: 12), + _buildInfoRow('网关序列号', task.sn), + const SizedBox(height: 12), + _buildInfoRow('任务类型', task.taskType), + const SizedBox(height: 12), + _buildInfoRow('航线UUID', task.waylineUuid), + const SizedBox(height: 12), + _buildInfoRow('开始时间', _formatDateTime(task.beginAt)), + const SizedBox(height: 12), + _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 { } } + 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 { ), 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 { ); } - 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 { ), 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 { 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 { 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,19 +638,28 @@ class _DroneMissionControlPageState extends State { ), elevation: 0, ), - child: const Text( - '暂停任务', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), + child: _isPauseLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + _isPausing ? '取消暂停' : '暂停任务', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), ), ), 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,13 +668,19 @@ class _DroneMissionControlPageState extends State { borderRadius: BorderRadius.circular(8), ), ), - child: const Text( - '返航降落', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), + child: _isReturnHomeLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + _isReturningHome ? '取消返航' : '返航降落', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), ), ), ], @@ -415,21 +690,25 @@ class _DroneMissionControlPageState extends State { Widget _buildInfoRow(String label, String value) { return Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - label, - style: const TextStyle( - fontSize: 13, - color: Color(0xFF86909C), + SizedBox( + width: 100, + child: Text( + label, + style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)), ), ), - const Spacer(), - Text( - value, - style: const TextStyle( - fontSize: 13, - color: Color(0xFF1D2129), - fontWeight: FontWeight.w500, + 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 { 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 { 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 createState() => _CreateTaskDialogState(); +} + +class _CreateTaskDialogState extends State { + final _formKey = GlobalKey(); + + // 表单字段控制器 + 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 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( + 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('任务创建成功(预留接口)'))); + } + } } diff --git a/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart b/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart index ba53ce39..73dd14fe 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart @@ -28,6 +28,8 @@ class DroneStationDetailPage extends StatefulWidget { class _DroneStationDetailPageState extends State { late DroneStationBloc _bloc; + UAVDetailEntity? _detail; // 无人机详情数据 + String? _droneSn; // 无人机序列号 // 悬浮视频监控状态 bool showFloatingMonitor = false; @@ -52,6 +54,9 @@ class _DroneStationDetailPageState extends State { // 加载超时计时器 Timer? _floatingLoadingTimer; static const _floatingLoadingTimeout = Duration(seconds: 15); + + // 无人机状态轮询计时器 + Timer? _droneStatusPollingTimer; @override void initState() { @@ -63,6 +68,9 @@ class _DroneStationDetailPageState extends State { deviceSn: widget.station.deviceSn, ), ); + + // 启动无人机状态轮询(每5秒刷新一次) + _startDroneStatusPolling(); } @override @@ -70,9 +78,29 @@ class _DroneStationDetailPageState extends State { _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 { } Widget _buildContent(UAVDetailEntity detail) { + _detail = detail; // 保存详情数据供其他方法使用 + _droneSn = detail.deviceSn; // 保存无人机序列号 return Stack( children: [ ListView( @@ -371,7 +401,11 @@ class _DroneStationDetailPageState extends State { 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 { Navigator.push( context, MaterialPageRoute( - builder: (context) => - DroneMissionControlPage(selectedTasks: tasks), + builder: (context) => DroneMissionControlPage( + selectedTasks: tasks, + droneSn: _droneSn, + ), ), ); } diff --git a/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart b/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart index f2ed7b1f..eb58e031 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart @@ -1,75 +1,416 @@ 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? cameraList; // 摄像头列表 + + const DroneVideoControlPage({ + super.key, + required this.droneSn, + this.cameraList, + }); @override State createState() => _DroneVideoControlPageState(); } class _DroneVideoControlPageState extends State { + 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 - 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, - actions: [ - IconButton( - icon: const Icon(Icons.settings, color: Color(0xFF1D2129)), - onPressed: () {}, - ), - ], - ), - body: 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(), - ], - ), - ), - ], + void initState() { + super.initState(); + _bloc = sl(); + + // 初始化事件处理器 + _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 _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, ), ); } - /// Tab切换栏 + /// 切换摄像头 + 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 _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 BlocProvider.value( + value: _bloc, + child: 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '无人机视频回传 / 远程控制', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF1D2129), + ), + ), + if (widget.cameraList != null && widget.cameraList!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _currentCamera, + hint: const Text( + '选择摄像头', + style: TextStyle(fontSize: 12, color: Color(0xFF86909C)), + ), + items: widget.cameraList!.map((camera) { + return DropdownMenuItem( + 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)), + onPressed: () {}, + ), + ], + ), + body: BlocConsumer( + 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: [ + _buildTabBar(), + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildVideoPlayer(), + const SizedBox(height: 12), + _buildFlightData(), + const SizedBox(height: 12), + _buildAIResults(), + const SizedBox(height: 12), + _buildMapAndJoystick(), + const SizedBox(height: 16), + _buildBottomToolbar(), + ], + ), + ), + ], + ); + }, + ), + ), + ); + } + + 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 { ); } - /// 视频播放器 Widget _buildVideoPlayer() { return Stack( children: [ @@ -127,76 +467,122 @@ class _DroneVideoControlPageState extends State { ), 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(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( - 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: PopupMenuButton( + icon: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(4), + ), + 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) => >[ + const PopupMenuItem(value: UavLensType.wide, child: Text('广角')), + const PopupMenuItem(value: UavLensType.zoom, child: Text('变焦')), + const PopupMenuItem(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 { 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 { 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 { ); } - /// 底部工具栏 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 { 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))), ], ), ); } } + diff --git a/lib/features/v2/device_list/presentation/pages/uav_live_video_page.dart b/lib/features/v2/device_list/presentation/pages/uav_live_video_page.dart new file mode 100644 index 00000000..98a7e441 --- /dev/null +++ b/lib/features/v2/device_list/presentation/pages/uav_live_video_page.dart @@ -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 createState() => _UavLiveVideoPageState(); +} + +class _UavLiveVideoPageState extends State { + late DroneStationBloc _bloc; + UavVideoStreamEntity? _videoStream; + bool _isLoading = false; + String? _errorMessage; + UavLensType? _currentLensType; + + @override + void initState() { + super.initState(); + _bloc = sl(); + // 默认加载广角镜头 + _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( + 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( + 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, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/features/v2/device_list/presentation/widgets/drone_station_status_widget.dart b/lib/features/v2/device_list/presentation/widgets/drone_station_status_widget.dart index d2ae2cf2..dacfca7b 100644 --- a/lib/features/v2/device_list/presentation/widgets/drone_station_status_widget.dart +++ b/lib/features/v2/device_list/presentation/widgets/drone_station_status_widget.dart @@ -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(0xFF00B42A)), + valueColor: const AlwaysStoppedAnimation( + Color(0xFF00B42A), + ), minHeight: 6, ), ), diff --git a/lib/features/v2/device_list/presentation/widgets/flight_task_selector_modal.dart b/lib/features/v2/device_list/presentation/widgets/flight_task_selector_modal.dart index 83fa5acc..bf02108c 100644 --- a/lib/features/v2/device_list/presentation/widgets/flight_task_selector_modal.dart +++ b/lib/features/v2/device_list/presentation/widgets/flight_task_selector_modal.dart @@ -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,16 +41,49 @@ class _FlightTaskSelectorModalState extends State { } Future _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) { - setState(() { - _selectedDateRange = picked; - }); + + 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 = 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, + ), + ); + }); + } + } } } @@ -95,6 +129,7 @@ class _FlightTaskSelectorModalState extends State { data.forEach((sn, value) { if (value != null && value['list'] != null) { final list = value['list'] as List; + print(' [FlightTask] API返回的原始数据: $list'); result[sn] = list.map((item) => FlightTaskEntity.fromJson(item)).toList(); } else { result[sn] = null; @@ -129,15 +164,30 @@ class _FlightTaskSelectorModalState extends State { const SizedBox(height: 12), _buildDeviceSelector(), const SizedBox(height: 16), - ElevatedButton( - onPressed: _isLoading ? null : _loadTasks, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF165DFF), - minimumSize: const Size(double.infinity, 48), - ), - child: _isLoading - ? const CircularProgressIndicator(color: Colors.white) - : const Text('查询任务'), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: _isLoading ? null : _loadTasks, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF165DFF), + minimumSize: const Size(double.infinity, 48), + ), + child: _isLoading + ? 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), @@ -173,7 +223,7 @@ class _FlightTaskSelectorModalState extends State { 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 { 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 { return dateTimeStr; } } -} + + // 显示创建任务对话框 + void _showCreateTaskDialog() { + Navigator.pop(context); // 先关闭当前模态页 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CreateTaskPage(sn: widget.currentGatewaySn), + ), + ); + } +} \ No newline at end of file