无人机视频对接+支持无人机未在线时候5S执行一下查询操作。便于视频正常展示。

无人机视频的摄像头切换功能和参数切换功能(广角和红外 和等)
无人机航线任务创建页面的开发和接口对接
无人机航线任务搜索接口(支持时间过滤)
无人机执行航线任务功能开发和接口对接
无人机暂停执行任务功能开发和接口对接
无人机返航任务和取消返航功能开发和接口对接
无人机机场详情页中的展示信息的更正。
This commit is contained in:
2026-06-11 17:30:15 +08:00
parent 058a5d9588
commit 8eb878dd81
22 changed files with 2991 additions and 432 deletions

View File

@@ -39,4 +39,22 @@ class HttpApiConsts {
// 获取飞行任务列表 // 获取飞行任务列表
static const String getFlightTask = "$baseUrl/iot/UAV/getFlightTask"; 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";
} }

View File

@@ -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/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_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_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/drone_station_bloc.dart';
import '../../features/v2/device_list/presentation/bloc/robot_list_bloc.dart'; import '../../features/v2/device_list/presentation/bloc/robot_list_bloc.dart';
import '../../features/v2/waring_center/data/datasources/alarm_remote_datasource.dart'; import '../../features/v2/waring_center/data/datasources/alarm_remote_datasource.dart';
@@ -301,8 +303,14 @@ Future<void> init() async {
sl.registerLazySingleton<GetVideoStreamUseCase>( sl.registerLazySingleton<GetVideoStreamUseCase>(
() => GetVideoStreamUseCase(sl()), () => GetVideoStreamUseCase(sl()),
); );
sl.registerLazySingleton<GetUavVideoStreamUseCase>(
() => GetUavVideoStreamUseCase(sl()),
);
sl.registerLazySingleton<UpdateFlightTaskStatusUseCase>(
() => UpdateFlightTaskStatusUseCase(sl()),
);
sl.registerFactory<DroneStationBloc>( sl.registerFactory<DroneStationBloc>(
() => DroneStationBloc(sl(), sl(), sl()), () => DroneStationBloc(sl(), sl(), sl(), sl()),
); );
/// Robot List V2 /// Robot List V2

View File

@@ -1,6 +1,7 @@
import '../../domain/entities/drone_station_entity.dart'; import '../../domain/entities/drone_station_entity.dart';
import '../../domain/entities/video_stream_entity.dart'; import '../../domain/entities/video_stream_entity.dart';
import '../../domain/entities/flight_task_entity.dart'; import '../../domain/entities/flight_task_entity.dart';
import '../../domain/entities/uav_video_stream_entity.dart';
abstract class DroneStationDataSource { abstract class DroneStationDataSource {
Future<List<DroneStationEntity>> getDroneStationList(int siteId); Future<List<DroneStationEntity>> getDroneStationList(int siteId);
@@ -17,4 +18,20 @@ abstract class DroneStationDataSource {
required int beginAt, required int beginAt,
required int endAt, required int endAt,
}); });
}
Future<FlightTaskEntity> getFlightTaskDetail(String taskId);
Future<Map<String, dynamic>> updateFlightTaskStatus({
required String taskId,
required String status,
});
/// 切换无人机镜头获取实时视频流
Future<UavVideoStreamEntity> getUavVideoStream({
required String sn,
required String cameraIndex,
UavLensType? lensType,
VideoQualityType qualityType = VideoQualityType.adaptive,
int videoExpire = 720000000,
});
}

View File

@@ -1,9 +1,11 @@
import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart'; import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import '../datasources/drone_station_datasource.dart'; import '../datasources/drone_station_datasource.dart';
import '../../domain/entities/drone_station_entity.dart'; import '../../domain/entities/drone_station_entity.dart';
import '../../domain/entities/video_stream_entity.dart'; import '../../domain/entities/video_stream_entity.dart';
import '../../domain/entities/flight_task_entity.dart'; import '../../domain/entities/flight_task_entity.dart';
import '../../domain/entities/uav_video_stream_entity.dart';
class DroneStationDataSourceImpl implements DroneStationDataSource { class DroneStationDataSourceImpl implements DroneStationDataSource {
final Dio dio; final Dio dio;
@@ -134,4 +136,198 @@ class DroneStationDataSourceImpl implements DroneStationDataSource {
return result; return result;
} }
}
@override
Future<FlightTaskEntity> getFlightTaskDetail(String taskId) async {
final response = await dio.get(
HttpApiConsts.getFlightTaskDetail,
queryParameters: {'taskId': taskId},
);
if (response.statusCode != 200) {
throw Exception('网络请求失败: ${response.statusCode}');
}
final responseData = response.data;
if (responseData['code'] != 0 && responseData['code'] != 200) {
throw Exception(responseData['message'] ?? '业务异常');
}
return FlightTaskEntity.fromJson(responseData['data']);
}
@override
Future<Map<String, dynamic>> updateFlightTaskStatus({
required String taskId,
required String status,
}) async {
final response = await dio.post(
HttpApiConsts.updateFlightTaskStatus,
queryParameters: {
'taskId': taskId,
'status': status,
},
);
if (response.statusCode != 200) {
throw Exception('网络请求失败: ${response.statusCode}');
}
final responseData = response.data;
if (responseData['code'] != 0) {
throw Exception(responseData['message'] ?? '业务异常');
}
return responseData;
}
@override
Future<UavVideoStreamEntity> getUavVideoStream({
required String sn,
required String cameraIndex,
UavLensType? lensType,
VideoQualityType qualityType = VideoQualityType.adaptive,
int videoExpire = 720000000,
}) async {
try {
print('=== 开始请求无人机视频流 ===');
print('URL: ${HttpApiConsts.changeUAVLens}');
print('📤 请求参数:');
print(' sn: $sn');
print(' cameraIndex: $cameraIndex ⬅️ 重点检查这个');
print(' lensType: ${_lensTypeToString(lensType)}');
print(' qualityType: ${_qualityTypeToString(qualityType)}');
print(' videoExpire: $videoExpire');
final response = await dio.post(
HttpApiConsts.changeUAVLens,
data: {
'sn': sn,
'lensType': lensType != null ? _lensTypeToString(lensType) : '',
'cameraIndex': cameraIndex,
'qualityType': _qualityTypeToString(qualityType),
'videoExpire': videoExpire,
},
options: Options(
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
),
);
print('响应状态码: ${response.statusCode}');
print('响应数据类型: ${response.data.runtimeType}');
if (response.statusCode != 200) {
throw Exception('网络请求失败: ${response.statusCode}');
}
var responseData = response.data;
// 打印完整响应,方便调试
print('完整响应: $responseData');
// 检查是否为字符串类型,需要手动解析
if (responseData is String) {
print('检测到响应为字符串类型,尝试 JSON 解析...');
try {
final parsed = jsonDecode(responseData);
if (parsed is Map<String, dynamic>) {
print('JSON 解析成功');
responseData = parsed;
} else {
throw Exception('JSON 解析结果不是 Map 类型');
}
} catch (e) {
print('JSON 解析失败: $e');
throw Exception('响应数据格式错误: $e');
}
}
print('响应数据键列表: ${(responseData as Map).keys?.toList()}');
if (responseData['code'] != 200) {
final errorMsg = responseData['msg'] ?? '业务异常';
print('业务错误: $errorMsg');
throw Exception(errorMsg);
}
final data = responseData['data'];
print('\n=== data 字段内容 ===');
print('data: $data');
print('data 类型: ${data.runtimeType}');
if (data == null) {
print('错误: data 字段为 null!');
throw Exception('服务器返回的 data 字段为空');
}
print('data.sn: ${data['sn']}');
print('data.camera_index: ${data['camera_index']}');
print('data.url: ${data['url']}');
print('data.expire_ts: ${data['expire_ts']}');
print('data.url_type: ${data['url_type']}');
print('=========================\n');
return UavVideoStreamEntity.fromJson(data);
} on DioException catch (e) {
print('=== Dio 异常捕获 ===');
print('异常类型: ${e.type}');
print('异常消息: ${e.message}');
print('异常详情: $e');
String errorMessage;
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
errorMessage = '网络连接超时,请检查网络';
break;
case DioExceptionType.connectionError:
errorMessage = '网络连接失败,请检查网络设置';
break;
case DioExceptionType.badResponse:
errorMessage = '服务器响应错误: ${e.response?.statusCode}';
break;
default:
errorMessage = '网络请求失败: ${e.message}';
}
throw Exception(errorMessage);
} catch (e) {
print('=== 未知异常 ===');
print('异常类型: ${e.runtimeType}');
print('异常消息: $e');
rethrow;
}
}
/// 将镜头类型枚举转换为字符串
String _lensTypeToString(UavLensType? type) {
switch (type) {
case UavLensType.wide:
return 'wide';
case UavLensType.zoom:
return 'zoom';
case UavLensType.ir:
return 'ir';
default:
return '';
}
}
/// 将质量类型枚举转换为字符串
String _qualityTypeToString(VideoQualityType type) {
switch (type) {
case VideoQualityType.adaptive:
return 'adaptive';
case VideoQualityType.low:
return 'low';
case VideoQualityType.medium:
return 'medium';
case VideoQualityType.high:
return 'high';
}
}
}

View File

@@ -3,6 +3,8 @@ import '../../../../../core/error/failure.dart';
import '../datasources/drone_station_datasource.dart'; import '../datasources/drone_station_datasource.dart';
import '../../domain/entities/drone_station_entity.dart'; import '../../domain/entities/drone_station_entity.dart';
import '../../domain/entities/video_stream_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'; import '../../domain/repositories/drone_station_repository.dart';
class DroneStationRepositoryImpl implements DroneStationRepository { class DroneStationRepositoryImpl implements DroneStationRepository {
@@ -52,4 +54,70 @@ class DroneStationRepositoryImpl implements DroneStationRepository {
return Left(Failure(e.toString())); return Left(Failure(e.toString()));
} }
} }
@override
Future<Either<Failure, Map<String, List<FlightTaskEntity>?>>> getFlightTasks({
required List<String> sns,
required int beginAt,
required int endAt,
}) async {
try {
final tasks = await dataSource.getFlightTasks(
sns: sns,
beginAt: beginAt,
endAt: endAt,
);
return Right(tasks);
} catch (e) {
return Left(Failure(e.toString()));
}
}
@override
Future<Either<Failure, FlightTaskEntity>> getFlightTaskDetail(String taskId) async {
try {
final detail = await dataSource.getFlightTaskDetail(taskId);
return Right(detail);
} catch (e) {
return Left(Failure(e.toString()));
}
}
@override
Future<Either<Failure, Map<String, dynamic>>> updateFlightTaskStatus({
required String taskId,
required String status,
}) async {
try {
final result = await dataSource.updateFlightTaskStatus(
taskId: taskId,
status: status,
);
return Right(result);
} catch (e) {
return Left(Failure(e.toString()));
}
}
@override
Future<Either<Failure, UavVideoStreamEntity>> getUavVideoStream({
required String sn,
required String cameraIndex,
UavLensType? lensType,
VideoQualityType qualityType = VideoQualityType.adaptive,
int videoExpire = 720000000,
}) async {
try {
final videoStream = await dataSource.getUavVideoStream(
sn: sn,
cameraIndex: cameraIndex,
lensType: lensType,
qualityType: qualityType,
videoExpire: videoExpire,
);
return Right(videoStream);
} catch (e) {
return Left(Failure(e.toString()));
}
}
} }

View File

@@ -76,12 +76,12 @@ class PositionState extends Equatable {
/// UAV详情实体(用于详情页面API返回的数据) /// UAV详情实体(用于详情页面API返回的数据)
class UAVDetailEntity extends Equatable { class UAVDetailEntity extends Equatable {
final String deviceSn; final String deviceSn; // 设备序列号(无人机序列号)
final String gatewaySn; final String gatewaySn; // 网关序列号
final String callsign; final String callsign; // 机场呼号/名称
final String droneCallsign; final String droneCallsign; // 无人机呼号
final int onlineStatus; final int onlineStatus; // 机场在线状态 (1:在线, 0:离线)
final int droneOnlineStatus; final int droneOnlineStatus; // 无人机在线状态
final double? latitude; final double? latitude;
final double? longitude; final double? longitude;
final double? capacityPercent; final double? capacityPercent;
@@ -94,7 +94,7 @@ class UAVDetailEntity extends Equatable {
final double? liveCapacity; final double? liveCapacity;
final String? rainfall; final String? rainfall;
final List<CameraInfo>? gatewayCameraList; final List<CameraInfo>? gatewayCameraList;
final List<dynamic>? droneCameraList; final List<CameraInfo>? droneCameraList;
final int? orgId; final int? orgId;
final int? siteId; final int? siteId;
final int? userId; final int? userId;
@@ -126,7 +126,12 @@ class UAVDetailEntity extends Equatable {
factory UAVDetailEntity.fromJson(Map<String, dynamic> json) { factory UAVDetailEntity.fromJson(Map<String, dynamic> json) {
return UAVDetailEntity( return UAVDetailEntity(
deviceSn: json['device_sn'] ?? '', deviceSn:
json['device_sn'] ??
json['drone_sn'] ??
json['droneSn'] ??
json['droneDeviceSn'] ??
'',
gatewaySn: json['gateway_sn'] ?? '', gatewaySn: json['gateway_sn'] ?? '',
callsign: json['callsign'] ?? '', callsign: json['callsign'] ?? '',
droneCallsign: json['drone_callsign'] ?? '', droneCallsign: json['drone_callsign'] ?? '',
@@ -166,7 +171,11 @@ class UAVDetailEntity extends Equatable {
.map((item) => CameraInfo.fromJson(item)) .map((item) => CameraInfo.fromJson(item))
.toList() .toList()
: null, : null,
droneCameraList: json['drone_camera_list'], droneCameraList: json['drone_camera_list'] != null
? (json['drone_camera_list'] as List)
.map((item) => CameraInfo.fromJson(item as Map<String, dynamic>))
.toList()
: null,
orgId: json['orgId'], orgId: json['orgId'],
siteId: json['siteId'], siteId: json['siteId'],
userId: json['userId'], userId: json['userId'],
@@ -322,7 +331,11 @@ class DroneStationEntity extends Equatable {
.map((item) => CameraInfo.fromJson(item)) .map((item) => CameraInfo.fromJson(item))
.toList() .toList()
: null, : null,
droneCameraList: json['drone_camera_list'], droneCameraList: json['drone_camera_list'] != null
? (json['drone_camera_list'] as List)
.map((item) => CameraInfo.fromJson(item as Map<String, dynamic>))
.toList()
: null,
orgId: json['orgId'] ?? 0, orgId: json['orgId'] ?? 0,
siteId: json['siteId'] ?? 0, siteId: json['siteId'] ?? 0,
userId: json['userId'], userId: json['userId'],
@@ -387,4 +400,4 @@ class DroneStationEntity extends Equatable {
siteId, siteId,
userId, userId,
]; ];
} }

View File

@@ -0,0 +1,121 @@
/// 飞行任务详情实体(专门用于详情接口)
class FlightTaskDetailEntity {
final String name;
final String uuid;
final String taskType;
final String status;
final String sn;
final String droneSn; // 无人机序列号
final String waylineUuid;
final String beginAt;
final String endAt;
final String outOfControlAction; // 失控动作
final int rthAltitude; // 返航高度(米)
final String rthMode; // 返航模式
final String resumableStatus; // 断点续飞状态
final int folderId; // 文件夹ID(从folder_info中获取)
final int expectedFileCount; // 期望文件数
final int uploadedFileCount; // 已上传文件数
final String repeatType; // 重复类型
final int interval; // 间隔
final int weekOfMonth; // 每月第几周
final int minBatteryCapacity; // 最低电量(百分比)
FlightTaskDetailEntity({
required this.name,
required this.uuid,
required this.taskType,
required this.status,
required this.sn,
required this.droneSn,
required this.waylineUuid,
required this.beginAt,
required this.endAt,
required this.outOfControlAction,
required this.rthAltitude,
required this.rthMode,
required this.resumableStatus,
required this.folderId,
required this.expectedFileCount,
required this.uploadedFileCount,
required this.repeatType,
required this.interval,
required this.weekOfMonth,
required this.minBatteryCapacity,
});
factory FlightTaskDetailEntity.fromJson(Map<String, dynamic> json) {
// 安全处理 folder_info 嵌套结构
final rawFolderInfo = json['folder_info'];
final Map<String, dynamic> folderInfo = (rawFolderInfo is Map)
? Map<String, dynamic>.from(rawFolderInfo)
: <String, dynamic>{};
print(
'🔍 [FlightTaskDetailEntity] folder_info 类型: ${rawFolderInfo.runtimeType}',
);
print('🔍 [FlightTaskDetailEntity] folder_info 值: $rawFolderInfo');
return FlightTaskDetailEntity(
name: json['name'] ?? '',
uuid: json['uuid'] ?? '',
taskType: json['task_type'] ?? '',
status: json['status'] ?? '',
sn: json['sn'] ?? '',
droneSn:
json['drone_sn'] ??
json['device_sn'] ??
json['droneSn'] ??
json['deviceSn'] ??
'',
waylineUuid: json['wayline_uuid'] ?? '',
beginAt: json['begin_at'] ?? '',
endAt: json['end_at'] ?? '',
outOfControlAction: json['out_of_control_action_in_flight'] ?? '',
rthAltitude: _parseInt(json['rth_altitude']),
rthMode: json['rth_mode'] ?? '',
resumableStatus: json['resumable_status'] ?? '',
folderId: _parseInt(folderInfo['folder_id']),
expectedFileCount: _parseInt(folderInfo['expected_file_count']),
uploadedFileCount: _parseInt(folderInfo['uploaded_file_count']),
repeatType: json['repeat_type'] ?? '',
interval: _parseInt(json['interval']),
weekOfMonth: _parseInt(json['week_of_month']),
minBatteryCapacity: _parseInt(json['min_battery_capacity']),
);
}
// 辅助方法:安全解析整数
static int _parseInt(dynamic value) {
if (value == null) return 0;
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
Map<String, dynamic> toJson() {
return {
'name': name,
'uuid': uuid,
'task_type': taskType,
'status': status,
'sn': sn,
'drone_sn': droneSn,
'wayline_uuid': waylineUuid,
'begin_at': beginAt,
'end_at': endAt,
'out_of_control_action_in_flight': outOfControlAction,
'rth_altitude': rthAltitude,
'rth_mode': rthMode,
'resumable_status': resumableStatus,
'folder_info': {
'folder_id': folderId,
'expected_file_count': expectedFileCount,
'uploaded_file_count': uploadedFileCount,
},
'repeat_type': repeatType,
'interval': interval,
'week_of_month': weekOfMonth,
'min_battery_capacity': minBatteryCapacity,
};
}
}

View File

@@ -1,3 +1,4 @@
/// 飞行任务列表实体(专门用于列表接口)
class FlightTaskEntity { class FlightTaskEntity {
final String name; final String name;
final String uuid; final String uuid;
@@ -54,9 +55,9 @@ class FlightTaskEntity {
runAt: json['run_at'] ?? '', runAt: json['run_at'] ?? '',
completedAt: json['completed_at'] ?? '', completedAt: json['completed_at'] ?? '',
waylineUuid: json['wayline_uuid'] ?? '', waylineUuid: json['wayline_uuid'] ?? '',
folderId: json['folder_id'] ?? 0, folderId: _parseInt(json['folder_id']),
currentWaypointIndex: json['current_waypoint_index'] ?? 0, currentWaypointIndex: _parseInt(json['current_waypoint_index']),
totalWaypoints: json['total_waypoints'] ?? 0, totalWaypoints: _parseInt(json['total_waypoints']),
mediaUploadStatus: json['media_upload_status'] ?? '', mediaUploadStatus: json['media_upload_status'] ?? '',
resumableStatus: json['resumable_status'] ?? '', resumableStatus: json['resumable_status'] ?? '',
isBreakPointResume: json['is_break_point_resume'] ?? false, isBreakPointResume: json['is_break_point_resume'] ?? false,
@@ -65,6 +66,14 @@ class FlightTaskEntity {
); );
} }
// 辅助方法:安全解析整数
static int _parseInt(dynamic value) {
if (value == null) return 0;
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'name': name, 'name': name,

View File

@@ -0,0 +1,93 @@
import 'package:equatable/equatable.dart';
/// 无人机镜头类型枚举
enum UavLensType {
wide, // 广角镜头
zoom, // 变焦镜头
ir, // 红外镜头
}
/// 视频质量类型枚举
enum VideoQualityType {
adaptive, // 自适应
low, // 低清晰度
medium, // 中清晰度
high, // 高清晰度
}
/// 无人机实时视频流实体
class UavVideoStreamEntity extends Equatable {
final String sn; // 设备序列号
final String cameraIndex; // 摄像头编号
final String url; // 视频流URL(含RTC参数)
final int expireTs; // Token过期时间戳
final String urlType; // URL类型:volc(火山引擎)或 agora
const UavVideoStreamEntity({
required this.sn,
required this.cameraIndex,
required this.url,
required this.expireTs,
required this.urlType,
});
factory UavVideoStreamEntity.fromJson(Map<String, dynamic> json) {
return UavVideoStreamEntity(
sn: json['sn'] ?? '',
cameraIndex: json['camera_index'] ?? '',
url: json['url'] ?? '',
expireTs: json['expire_ts'] ?? 0,
urlType: json['url_type'] ?? 'volc',
);
}
Map<String, String> toMap() {
return {
'sn': sn,
'cameraIndex': cameraIndex,
'url': url,
'expireTs': expireTs.toString(),
'urlType': urlType,
};
}
/// 解析RTC参数(从URL中提取)
Map<String, String> parseRtcParams() {
final params = <String, String>{};
final pairs = url.split('&');
for (final pair in pairs) {
final kv = pair.split('=');
if (kv.length == 2) {
params[kv[0]] = Uri.decodeComponent(kv[1]);
}
}
return params;
}
/// 获取 AppId
String get appId {
final params = parseRtcParams();
return params['app_id'] ?? params['appid'] ?? '';
}
/// 获取 RoomId
String get roomId {
final params = parseRtcParams();
return params['room_id'] ?? params['roomid'] ?? params['channel'] ?? '';
}
/// 获取 Token
String get token {
final params = parseRtcParams();
return params['token'] ?? '';
}
/// 获取 UserId
String get userId {
final params = parseRtcParams();
return params['user_id'] ?? params['uid'] ?? '';
}
@override
List<Object?> get props => [sn, cameraIndex, url, expireTs, urlType];
}

View File

@@ -2,6 +2,8 @@ import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart'; import '../../../../../core/error/failure.dart';
import '../entities/drone_station_entity.dart'; import '../entities/drone_station_entity.dart';
import '../entities/video_stream_entity.dart'; import '../entities/video_stream_entity.dart';
import '../entities/flight_task_entity.dart';
import '../entities/uav_video_stream_entity.dart';
abstract class DroneStationRepository { abstract class DroneStationRepository {
Future<Either<Failure, List<DroneStationEntity>>> getDroneStationList( Future<Either<Failure, List<DroneStationEntity>>> getDroneStationList(
@@ -16,4 +18,23 @@ abstract class DroneStationRepository {
required String cameraIndex, required String cameraIndex,
required String cameraPosition, required String cameraPosition,
}); });
Future<Either<Failure, Map<String, List<FlightTaskEntity>?>>> getFlightTasks({
required List<String> sns,
required int beginAt,
required int endAt,
});
Future<Either<Failure, FlightTaskEntity>> getFlightTaskDetail(String taskId);
Future<Either<Failure, Map<String, dynamic>>> updateFlightTaskStatus({
required String taskId,
required String status,
});
/// 获取无人机实时视频流
Future<Either<Failure, UavVideoStreamEntity>> getUavVideoStream({
required String sn,
required String cameraIndex,
UavLensType? lensType,
VideoQualityType qualityType,
int videoExpire,
});
} }

View File

@@ -0,0 +1,27 @@
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../entities/uav_video_stream_entity.dart';
import '../repositories/drone_station_repository.dart';
/// 获取无人机实时视频流用例
class GetUavVideoStreamUseCase {
final DroneStationRepository repository;
GetUavVideoStreamUseCase(this.repository);
Future<Either<Failure, UavVideoStreamEntity>> call({
required String sn,
required String cameraIndex,
UavLensType? lensType,
VideoQualityType qualityType = VideoQualityType.adaptive,
int videoExpire = 720000000,
}) async {
return await repository.getUavVideoStream(
sn: sn,
cameraIndex: cameraIndex,
lensType: lensType,
qualityType: qualityType,
videoExpire: videoExpire,
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../repositories/drone_station_repository.dart';
class UpdateFlightTaskStatusUseCase {
final DroneStationRepository repository;
UpdateFlightTaskStatusUseCase(this.repository);
Future<Either<Failure, Map<String, dynamic>>> execute({
required String taskId,
required String status,
}) async {
return await repository.updateFlightTaskStatus(
taskId: taskId,
status: status,
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecases/get_drone_station_list_usecase.dart'; import '../../domain/usecases/get_drone_station_list_usecase.dart';
import '../../domain/usecases/get_video_stream_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_event.dart';
import 'drone_station_state.dart'; import 'drone_station_state.dart';
@@ -8,16 +9,19 @@ class DroneStationBloc extends Bloc<DroneStationEvent, DroneStationState> {
final GetDroneStationListUseCase getDroneStationListUseCase; final GetDroneStationListUseCase getDroneStationListUseCase;
final GetUAVDetailUseCase getUAVDetailUseCase; final GetUAVDetailUseCase getUAVDetailUseCase;
final GetVideoStreamUseCase getVideoStreamUseCase; final GetVideoStreamUseCase getVideoStreamUseCase;
final GetUavVideoStreamUseCase getUavVideoStreamUseCase;
DroneStationBloc( DroneStationBloc(
this.getDroneStationListUseCase, this.getDroneStationListUseCase,
this.getUAVDetailUseCase, this.getUAVDetailUseCase,
this.getVideoStreamUseCase, this.getVideoStreamUseCase,
this.getUavVideoStreamUseCase,
) : super(const DroneStationInitial()) { ) : super(const DroneStationInitial()) {
on<DroneStationLoadData>(_onLoadData); on<DroneStationLoadData>(_onLoadData);
on<DroneStationRefresh>(_onRefresh); on<DroneStationRefresh>(_onRefresh);
on<UAVDetailLoad>(_onUAVDetailLoad); on<UAVDetailLoad>(_onUAVDetailLoad);
on<VideoStreamLoad>(_onVideoStreamLoad); on<VideoStreamLoad>(_onVideoStreamLoad);
on<UavVideoStreamLoad>(_onUavVideoStreamLoad);
} }
Future<void> _onLoadData( Future<void> _onLoadData(
@@ -79,4 +83,29 @@ class DroneStationBloc extends Bloc<DroneStationEvent, DroneStationState> {
(videoStream) => emit(VideoStreamLoaded(videoStream, event.cameraPosition)), (videoStream) => emit(VideoStreamLoaded(videoStream, event.cameraPosition)),
); );
} }
/// 处理无人机实时视频流加载事件
Future<void> _onUavVideoStreamLoad(
UavVideoStreamLoad event,
Emitter<DroneStationState> emit,
) async {
emit(const UavVideoStreamLoading());
final result = await getUavVideoStreamUseCase(
sn: event.sn,
cameraIndex: event.cameraIndex,
lensType: event.lensType,
qualityType: event.qualityType,
videoExpire: event.videoExpire,
);
result.fold(
(failure) => emit(UavVideoStreamError(failure.message)),
(videoStream) => emit(UavVideoStreamLoaded(
videoStream: videoStream,
cameraIndex: event.cameraIndex,
lensType: event.lensType,
)),
);
}
} }

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../domain/entities/uav_video_stream_entity.dart';
abstract class DroneStationEvent extends Equatable { abstract class DroneStationEvent extends Equatable {
const DroneStationEvent(); const DroneStationEvent();
@@ -49,3 +50,23 @@ class VideoStreamLoad extends DroneStationEvent {
@override @override
List<Object?> get props => [sn, cameraIndex, cameraPosition]; List<Object?> get props => [sn, cameraIndex, cameraPosition];
} }
/// 加载无人机实时视频流事件
class UavVideoStreamLoad extends DroneStationEvent {
final String sn;
final String cameraIndex;
final UavLensType? lensType;
final VideoQualityType qualityType;
final int videoExpire;
const UavVideoStreamLoad({
required this.sn,
required this.cameraIndex,
this.lensType,
this.qualityType = VideoQualityType.adaptive,
this.videoExpire = 720000000,
});
@override
List<Object?> get props => [sn, cameraIndex, lensType, qualityType, videoExpire];
}

View File

@@ -1,6 +1,7 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../domain/entities/drone_station_entity.dart'; import '../../domain/entities/drone_station_entity.dart';
import '../../domain/entities/video_stream_entity.dart'; import '../../domain/entities/video_stream_entity.dart';
import '../../domain/entities/uav_video_stream_entity.dart';
abstract class DroneStationState extends Equatable { abstract class DroneStationState extends Equatable {
const DroneStationState(); const DroneStationState();
@@ -76,6 +77,37 @@ class VideoStreamError extends DroneStationState {
const VideoStreamError(this.message); const VideoStreamError(this.message);
@override
List<Object?> get props => [message];
}
/// 无人机实时视频流加载状态
class UavVideoStreamLoading extends DroneStationState {
const UavVideoStreamLoading();
}
/// 无人机实时视频流加载成功状态
class UavVideoStreamLoaded extends DroneStationState {
final UavVideoStreamEntity videoStream;
final String cameraIndex;
final UavLensType? lensType;
const UavVideoStreamLoaded({
required this.videoStream,
required this.cameraIndex,
this.lensType,
});
@override
List<Object?> get props => [videoStream, cameraIndex, lensType];
}
/// 无人机实时视频流加载失败状态
class UavVideoStreamError extends DroneStationState {
final String message;
const UavVideoStreamError(this.message);
@override @override
List<Object?> get props => [message]; List<Object?> get props => [message];
} }

View File

@@ -0,0 +1,627 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import '../../../../../core/consts/http_api_consts.dart';
class CreateTaskPage extends StatefulWidget {
final String sn;
const CreateTaskPage({super.key, required this.sn});
@override
State<CreateTaskPage> createState() => _CreateTaskPageState();
}
class _CreateTaskPageState extends State<CreateTaskPage> {
final _formKey = GlobalKey<FormState>();
// 表单字段控制器
final TextEditingController _taskNameController = TextEditingController();
final TextEditingController _rthAltitudeController = TextEditingController(
text: '10',
);
final TextEditingController _minBatteryController = TextEditingController(
text: '50',
);
// 下拉选择值
String? _selectedWaylineName; // 航线名称(显示用)
String? _selectedWaylineUuid; // 航线UUID(接口用)
String _rthMode = '智能'; // 返航模式:智能/预设
String _precision = 'GPS'; // 航线精度:GPS/RTK
String _breakPointResume = '自动'; // 断点续飞:自动/手动
String _taskType = '立即'; // 任务类型:立即
// 航线列表(name + uuid)
List<Map<String, dynamic>> _waylineList = [];
bool _isLoadingWaylines = false;
final Dio _dio = Dio();
@override
void initState() {
super.initState();
_loadWaylines();
}
@override
void dispose() {
_taskNameController.dispose();
_rthAltitudeController.dispose();
_minBatteryController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F6F8),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2129)),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'创建航线任务',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
),
centerTitle: true,
),
body: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 第一行:航线名称 + 选择航线
Row(
children: [
Expanded(
child: _buildTextField(
label: '航线名称 *',
controller: _taskNameController,
hint: '请输入',
validator: (value) =>
value == null || value.isEmpty ? '请输入航线名称' : null,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildSelectableField(
label: '选择航线 *',
value: _selectedWaylineName,
hint: '请选择航线',
onTap: _showWaylineSelector,
),
),
],
),
const SizedBox(height: 16),
// 第二行:机场SN + 降落地场SN
Row(
children: [
Expanded(
child: _buildTextField(
label: '机场SN *',
controller: TextEditingController(text: widget.sn),
enabled: false,
hint: '固定就是当前机场的sn',
),
),
const SizedBox(width: 16),
Expanded(
child: _buildTextField(
label: '降落地场SN',
controller: TextEditingController(),
hint: '请输入',
),
),
],
),
const SizedBox(height: 16),
// 第三行:返航高度 + 返航模式
Row(
children: [
Expanded(
child: _buildTextField(
label: '返航高度(m)',
controller: _rthAltitudeController,
keyboardType: TextInputType.number,
hint: '默认是10,可以上下调',
),
),
const SizedBox(width: 16),
Expanded(
child: _buildDropdownField(
label: '返航模式',
value: _rthMode,
items: ['智能', '预设'],
onChanged: (value) => setState(() => _rthMode = value!),
),
),
],
),
const SizedBox(height: 16),
// 第四行:航线精度 + 断点续飞
Row(
children: [
Expanded(
child: _buildDropdownField(
label: '航线精度',
value: _precision,
items: ['GPS', 'RTK'],
onChanged: (value) => setState(() => _precision = value!),
),
),
const SizedBox(width: 16),
Expanded(
child: _buildDropdownField(
label: '断点续飞',
value: _breakPointResume,
items: ['自动', '手动'],
onChanged: (value) =>
setState(() => _breakPointResume = value!),
),
),
],
),
const SizedBox(height: 16),
// 第五行:最低电量 + 任务类型
Row(
children: [
Expanded(
child: _buildTextField(
label: '最低电量(%)',
controller: _minBatteryController,
keyboardType: TextInputType.number,
hint: '手动输入',
),
),
const SizedBox(width: 16),
Expanded(
child: _buildDropdownField(
label: '任务类型',
value: _taskType,
items: ['立即'],
onChanged: (value) => setState(() => _taskType = value!),
),
),
],
),
const SizedBox(height: 24),
// 底部按钮
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Color(0xFFE5E6EB)),
minimumSize: const Size(80, 36),
),
child: const Text('取消'),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _onCreateTask,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
minimumSize: const Size(100, 36),
),
child: const Text('确认创建'),
),
],
),
],
),
),
),
);
}
Widget _buildTextField({
required String label,
required TextEditingController controller,
String? hint,
bool enabled = true,
TextInputType? keyboardType,
String? Function(String?)? validator,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1D2129),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
TextFormField(
controller: controller,
enabled: enabled,
keyboardType: keyboardType,
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(fontSize: 13, color: Color(0xFFC9CDD4)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFE5E6EB)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFE5E6EB)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFF165DFF)),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFF2F3F5)),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
validator: validator,
),
],
);
}
Widget _buildDropdownField({
required String label,
required String? value,
required List<String> items,
required void Function(String?) onChanged,
String? hint,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1D2129),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: value,
hint: hint != null
? Text(
hint,
style: const TextStyle(
fontSize: 13,
color: Color(0xFFC9CDD4),
),
)
: null,
items: items.map((item) {
return DropdownMenuItem(value: item, child: Text(item));
}).toList(),
onChanged: onChanged,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFE5E6EB)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFE5E6EB)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFF165DFF)),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
),
],
);
}
void _onCreateTask() {
if (_formKey.currentState!.validate()) {
// 验证航线是否选择
if (_selectedWaylineUuid == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请选择航线')));
return;
}
_submitCreateTask();
}
}
// 提交创建任务
Future<void> _submitCreateTask() async {
try {
print('🔍 [CreateTask] 开始创建任务');
// 构建请求参数
final requestData = {
'name': _taskNameController.text,
'sn': widget.sn,
'landing_dock_sn': '', // 降落地场SN(可选)
'rth_altitude': int.parse(_rthAltitudeController.text),
'rth_mode': _rthMode == '智能' ? 'optimal' : 'straight_line',
'wayline_precision_type': _precision.toLowerCase(),
'resumable_status': _breakPointResume == '自动' ? 'auto' : 'manual',
'min_battery_capacity': int.parse(_minBatteryController.text),
'task_type': 'immediate',
'repeat_type': 'nonrepeating',
'time_zone': 'Asia/Shanghai',
'begin_at': null,
'end_at': null,
'recurring_task_start_time_list': [],
'wayline_uuid': _selectedWaylineUuid,
};
print('📦 [CreateTask] 请求参数: $requestData');
// 调用接口
final response = await _dio.post(
HttpApiConsts.createFlightTask,
data: requestData,
);
print('📡 [CreateTask] 响应状态码: ${response.statusCode}');
print('📦 [CreateTask] 响应数据: ${response.data}');
if (response.statusCode == 200) {
final Map<String, dynamic> jsonData = (response.data is String)
? json.decode(response.data)
: Map<String, dynamic>.from(response.data);
if (jsonData['code'] == 0 || jsonData['code'] == 200) {
final taskUuid = jsonData['data']['task_uuid'];
print('✅ [CreateTask] 任务创建成功, task_uuid: $taskUuid');
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('任务创建成功,任务ID: $taskUuid')));
} else {
throw Exception(jsonData['message'] ?? '创建任务失败');
}
} else {
throw Exception('网络请求失败');
}
} catch (e) {
print('❌ [CreateTask] 创建任务失败: $e');
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('创建任务失败: $e')));
}
}
// 加载航线列表
Future<void> _loadWaylines() async {
setState(() {
_isLoadingWaylines = true;
});
try {
print('🔍 [CreateTask] 开始加载航线列表, sn: ${widget.sn}');
final response = await _dio.get(
HttpApiConsts.getWayline,
queryParameters: {'sn': widget.sn},
);
print('📡 [CreateTask] 响应状态码: ${response.statusCode}');
print('📦 [CreateTask] 响应数据类型: ${response.data.runtimeType}');
print('📦 [CreateTask] 响应数据: ${response.data}');
if (response.statusCode == 200) {
// 如果 response.data 是 String,手动解析 JSON
final Map<String, dynamic> jsonData = (response.data is String)
? json.decode(response.data)
: Map<String, dynamic>.from(response.data);
if (jsonData['code'] == 200 || jsonData['code'] == 0) {
final dataField = jsonData['data'];
print('🔍 [CreateTask] data 类型: ${dataField.runtimeType}');
print('🔍 [CreateTask] data 值: $dataField');
// data 可能是 List 或包含 list 字段的对象
List<dynamic>? waylinesData;
if (dataField is List) {
waylinesData = dataField;
} else if (dataField is Map && dataField.containsKey('list')) {
waylinesData = dataField['list'];
print('🔍 [CreateTask] 从 data.list 获取航线列表');
}
if (waylinesData != null && waylinesData is List) {
setState(() {
_waylineList = waylinesData!.map((item) {
if (item is Map) {
return Map<String, dynamic>.from(item);
}
return {'name': item.toString()};
}).toList();
});
print('✅ [CreateTask] 加载航线列表成功,共 ${_waylineList.length} 条');
} else {
print('❌ [CreateTask] 未找到航线列表数据');
}
} else {
print(
'❌ [CreateTask] 业务异常, code: ${jsonData["code"]}, message: ${jsonData["message"]}',
);
}
} else {
print('❌ [CreateTask] HTTP 错误, status: ${response.statusCode}');
}
} catch (e) {
print('❌ [CreateTask] 加载航线列表失败: $e');
} finally {
setState(() {
_isLoadingWaylines = false;
});
}
}
// 显示航线选择器
void _showWaylineSelector() {
if (_waylineList.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('暂无可用航线')));
return;
}
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'选择航线',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 16),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: _waylineList.length,
itemBuilder: (context, index) {
final wayline = _waylineList[index];
final waylineName = wayline['name'] ?? '未命名航线';
// 兼容多种字段名
final waylineUuid =
wayline['uuid'] ??
wayline['waylineUuid'] ??
wayline['id'] ??
'';
// print('🔍 [CreateTask] 航线数据: $wayline');
// print('🔍 [CreateTask] 解析的UUID: $waylineUuid');
return ListTile(
title: Text(waylineName),
subtitle: waylineUuid.isNotEmpty
? Text(
waylineUuid,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF86909C),
),
)
: null,
trailing: _selectedWaylineName == waylineName
? const Icon(Icons.check, color: Color(0xFF165DFF))
: null,
onTap: () {
setState(() {
_selectedWaylineName = waylineName;
_selectedWaylineUuid = waylineUuid;
});
print(
'✅ [CreateTask] 已选择航线: $_selectedWaylineName, UUID: $_selectedWaylineUuid',
);
Navigator.pop(context);
},
);
},
),
),
],
),
);
},
);
}
// 可点击的选择字段
Widget _buildSelectableField({
required String label,
required String? value,
required String hint,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1D2129),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E6EB)),
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
Expanded(
child: Text(
value ?? hint,
style: TextStyle(
fontSize: 13,
color: value != null
? const Color(0xFF1D2129)
: const Color(0xFFC9CDD4),
),
),
),
const Icon(Icons.arrow_drop_down, color: Color(0xFF86909C)),
],
),
),
),
],
);
}
}

View File

@@ -28,6 +28,8 @@ class DroneStationDetailPage extends StatefulWidget {
class _DroneStationDetailPageState extends State<DroneStationDetailPage> { class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
late DroneStationBloc _bloc; late DroneStationBloc _bloc;
UAVDetailEntity? _detail; // 无人机详情数据
String? _droneSn; // 无人机序列号
// 悬浮视频监控状态 // 悬浮视频监控状态
bool showFloatingMonitor = false; bool showFloatingMonitor = false;
@@ -52,6 +54,9 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
// 加载超时计时器 // 加载超时计时器
Timer? _floatingLoadingTimer; Timer? _floatingLoadingTimer;
static const _floatingLoadingTimeout = Duration(seconds: 15); static const _floatingLoadingTimeout = Duration(seconds: 15);
// 无人机状态轮询计时器
Timer? _droneStatusPollingTimer;
@override @override
void initState() { void initState() {
@@ -63,6 +68,9 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
deviceSn: widget.station.deviceSn, deviceSn: widget.station.deviceSn,
), ),
); );
// 启动无人机状态轮询(每5秒刷新一次)
_startDroneStatusPolling();
} }
@override @override
@@ -70,9 +78,29 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
_bloc.close(); _bloc.close();
_destroyFloatingRtcEngine(); _destroyFloatingRtcEngine();
_floatingLoadingTimer?.cancel(); _floatingLoadingTimer?.cancel();
_droneStatusPollingTimer?.cancel(); // 停止轮询
super.dispose(); 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 引擎 // 销毁悬浮窗的 RTC 引擎
void _destroyFloatingRtcEngine() async { void _destroyFloatingRtcEngine() async {
// 销毁火山引擎 RTC // 销毁火山引擎 RTC
@@ -202,6 +230,8 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
} }
Widget _buildContent(UAVDetailEntity detail) { Widget _buildContent(UAVDetailEntity detail) {
_detail = detail; // 保存详情数据供其他方法使用
_droneSn = detail.deviceSn; // 保存无人机序列号
return Stack( return Stack(
children: [ children: [
ListView( ListView(
@@ -371,7 +401,11 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => const DroneVideoControlPage(), builder: (context) => DroneVideoControlPage(
droneSn: detail.deviceSn,
// 使用无人机摄像头列表,如果为null则传空列表
cameraList: detail.droneCameraList ?? [],
),
), ),
); );
}, },
@@ -667,8 +701,10 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => builder: (context) => DroneMissionControlPage(
DroneMissionControlPage(selectedTasks: tasks), selectedTasks: tasks,
droneSn: _droneSn,
),
), ),
); );
} }

View File

@@ -1,75 +1,416 @@
import 'package:flutter/material.dart'; 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 { class DroneVideoControlPage extends StatefulWidget {
const DroneVideoControlPage({super.key}); final String droneSn; // 无人机设备序列号
final List<CameraInfo>? cameraList; // 摄像头列表
const DroneVideoControlPage({
super.key,
required this.droneSn,
this.cameraList,
});
@override @override
State<DroneVideoControlPage> createState() => _DroneVideoControlPageState(); State<DroneVideoControlPage> createState() => _DroneVideoControlPageState();
} }
class _DroneVideoControlPageState extends State<DroneVideoControlPage> { class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
late DroneStationBloc _bloc;
int _selectedTab = 0; // 0: 视频画面, 1: 热成像, 2: 抓拍记录 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 @override
Widget build(BuildContext context) { void initState() {
return Scaffold( super.initState();
backgroundColor: const Color(0xFFF5F6F8), _bloc = sl<DroneStationBloc>();
appBar: AppBar(
backgroundColor: Colors.white, // 初始化事件处理器
elevation: 0, _initVolcEventHandlers();
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2129)), // 打印接收到的参数
onPressed: () => Navigator.pop(context), debugPrint('=== 视频控制页面接收参数 ===');
), debugPrint('droneSn: ${widget.droneSn}');
title: const Text( debugPrint('cameraList: ${widget.cameraList}');
'无人机视频回传 / 远程控制', if (widget.cameraList != null) {
style: TextStyle( for (var camera in widget.cameraList!) {
fontSize: 18, debugPrint(' - cameraIndex: ${camera.cameraIndex}');
fontWeight: FontWeight.bold, }
color: Color(0xFF1D2129), }
), debugPrint('===========================\n');
),
centerTitle: true, // 默认选择第一个摄像头
actions: [ _currentCamera = widget.cameraList?.isNotEmpty == true
IconButton( ? widget.cameraList!.first
icon: const Icon(Icons.settings, color: Color(0xFF1D2129)), : null;
onPressed: () {},
), debugPrint('默认选择的摄像头: ${_currentCamera?.cameraIndex}');
],
), // 默认加载广角镜头
body: Column( if (_currentCamera != null) {
children: [ _loadVideoStream(UavLensType.wide);
// Tab切换 }
_buildTabBar(), }
// 内容区域
Expanded( @override
child: ListView( void dispose() {
padding: const EdgeInsets.all(16), _destroyRtcEngine();
children: [ _bloc.close();
// 视频画面 super.dispose();
_buildVideoPlayer(), }
const SizedBox(height: 12),
// 飞行数据 // 销毁 RTC 引擎
_buildFlightData(), Future<void> _destroyRtcEngine() async {
const SizedBox(height: 12), if (_rtcRoom != null) {
// AI识别结果 try {
_buildAIResults(), await _rtcRoom?.leaveRoom();
const SizedBox(height: 12), } catch (_) {}
// 地图和摇杆 _rtcRoom = null;
_buildMapAndJoystick(), }
const SizedBox(height: 16), if (_rtcEngine != null) {
// 底部工具栏 try {
_buildBottomToolbar(), _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<void> _initRtcEngine(UavVideoStreamEntity stream) async {
final appId = stream.appId;
final roomId = stream.roomId;
final token = stream.token;
final userId = stream.userId.isNotEmpty
? stream.userId
: 'user_${DateTime.now().millisecondsSinceEpoch}';
debugPrint('=== VolcEngine 参数 ===');
debugPrint('AppId: "$appId"');
debugPrint('RoomId: "$roomId"');
debugPrint('UserId: "$userId"');
if (appId.isEmpty || roomId.isEmpty || token.isEmpty) {
setState(() {
_errorMessage = 'VolcEngine 参数缺失';
_isLoading = false;
});
return;
}
try {
debugPrint('🚀 创建 VolcEngine 引擎...');
_rtcEngine = await volc.RTCEngine.createRTCEngine(
volc.RTCVideoContext(appId: appId, eventHandler: _engineEventHandler),
);
if (_rtcEngine == null) {
setState(() {
_errorMessage = 'VolcEngine 引擎创建失败';
_isLoading = false;
});
return;
}
debugPrint('✅ VolcEngine 引擎创建成功');
_rtcRoom = await _rtcEngine?.createRTCRoom(roomId);
if (_rtcRoom == null) {
setState(() {
_errorMessage = 'VolcEngine 房间创建失败';
_isLoading = false;
});
return;
}
debugPrint('✅ VolcEngine 房间创建成功');
await _rtcRoom?.setRTCRoomEventHandler(_roomEventHandler);
debugPrint('🔑 加入 VolcEngine 房间...');
await _rtcRoom?.joinRoom(
token: token,
userInfo: volc.UserInfo(userId: userId, extraInfo: ''),
userVisibility: true,
roomConfig: volc.RoomConfig(
isPublishAudio: false,
isPublishVideo: false,
isAutoSubscribeAudio: false,
isAutoSubscribeVideo: true,
),
);
debugPrint('✅ 成功加入 VolcEngine 房间: $roomId');
debugPrint('⏳ 等待视频流推送...');
} catch (e) {
debugPrint('❌ VolcEngine 初始化失败: $e');
setState(() {
_errorMessage = 'VolcEngine 加入失败: $e';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return 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<CameraInfo>(
value: _currentCamera,
hint: const Text(
'选择摄像头',
style: TextStyle(fontSize: 12, color: Color(0xFF86909C)),
),
items: widget.cameraList!.map((camera) {
return DropdownMenuItem<CameraInfo>(
value: camera,
child: Text(
camera.cameraIndex,
style: const TextStyle(fontSize: 12),
),
);
}).toList(),
onChanged: (CameraInfo? newCamera) {
if (newCamera != null) {
_switchCamera(newCamera);
}
},
isDense: true,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF165DFF),
),
),
),
),
],
),
centerTitle: false,
actions: [
IconButton(
icon: const Icon(Icons.settings, color: Color(0xFF1D2129)),
onPressed: () {},
),
],
),
body: BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
if (state is UavVideoStreamLoaded) {
setState(() {
_videoStream = state.videoStream;
});
debugPrint('=== 视频流加载成功 ===');
debugPrint('URL Type: ${state.videoStream.urlType}');
if (state.videoStream.urlType == 'volc') {
_destroyRtcEngine();
_initRtcEngine(state.videoStream);
} else {
setState(() {
_errorMessage = '不支持的 URL 类型: ${state.videoStream.urlType}';
_isLoading = false;
});
}
} else if (state is UavVideoStreamError) {
setState(() {
_errorMessage = state.message;
_isLoading = false;
});
}
},
builder: (context, state) {
return Column(
children: [
_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() { Widget _buildTabBar() {
return Container( return Container(
color: Colors.white, color: Colors.white,
@@ -115,7 +456,6 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
); );
} }
/// 视频播放器
Widget _buildVideoPlayer() { Widget _buildVideoPlayer() {
return Stack( return Stack(
children: [ children: [
@@ -127,76 +467,122 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Image.asset( child: _isLoading
'assets/images/xunjian.png', ? Center(
fit: BoxFit.cover, child: Column(
width: double.infinity, mainAxisAlignment: MainAxisAlignment.center,
), children: [
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
const SizedBox(height: 12),
const Text(
'正在加载视频流...',
style: TextStyle(fontSize: 14, color: Colors.white),
),
],
),
)
: _errorMessage != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: Colors.white, size: 48),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 14, color: Colors.white),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => _loadVideoStream(_currentLensType ?? UavLensType.wide),
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF165DFF)),
child: const Text('重试'),
),
],
),
)
: _videoStream != null && _remoteRenderContext != null
? volc.RTCSurfaceView(
context: _remoteRenderContext!,
renderMode: volc.VideoRenderMode.fit,
)
: _videoStream != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.videocam_off, size: 48, color: Colors.grey),
const SizedBox(height: 12),
const Text('等待视频流推送...', style: TextStyle(fontSize: 14, color: Colors.white)),
],
),
)
: Image.asset('assets/images/xunjian.png', fit: BoxFit.cover, width: double.infinity),
), ),
), ),
// 录制标识
Positioned( Positioned(
top: 12, top: 12,
left: 12, left: 12,
child: Row( child: Row(
children: [ children: [
Container( Container(width: 8, height: 8, decoration: const BoxDecoration(color: Color(0xFFF53F3F), shape: BoxShape.circle)),
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFFF53F3F),
shape: BoxShape.circle,
),
),
const SizedBox(width: 6), const SizedBox(width: 6),
const Text( const Text('REC 00:12:36', style: TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w500)),
'REC 00:12:36',
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
], ],
), ),
), ),
// 缩放标识
Positioned( Positioned(
bottom: 12, bottom: 12,
right: 12, right: 12,
child: Container( child: PopupMenuButton<UavLensType>(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), icon: Container(
decoration: BoxDecoration( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
color: Colors.black.withOpacity(0.5), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4), color: Colors.black.withOpacity(0.5),
), borderRadius: BorderRadius.circular(4),
child: const Text( ),
'1.0x', child: Row(
style: TextStyle( mainAxisSize: MainAxisSize.min,
fontSize: 12, children: [
color: Colors.white, Text(_getLensTypeName(_currentLensType ?? UavLensType.wide), style: const TextStyle(fontSize: 12, color: Colors.white)),
const SizedBox(width: 4),
const Icon(Icons.arrow_drop_down, color: Colors.white, size: 16),
],
), ),
), ),
onSelected: (UavLensType lensType) {
debugPrint('👆 用户点击了镜头切换: ${lensType.name}');
if (lensType != _currentLensType) {
debugPrint('✅ 镜头类型不同,开始加载...');
_loadVideoStream(lensType);
} else {
debugPrint('⚠️ 镜头类型相同,忽略操作');
}
},
itemBuilder: (BuildContext context) => <PopupMenuEntry<UavLensType>>[
const PopupMenuItem<UavLensType>(value: UavLensType.wide, child: Text('广角')),
const PopupMenuItem<UavLensType>(value: UavLensType.zoom, child: Text('变焦')),
const PopupMenuItem<UavLensType>(value: UavLensType.ir, child: Text('红外')),
],
), ),
), ),
], ],
); );
} }
/// 飞行数据
Widget _buildFlightData() { Widget _buildFlightData() {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: const [ boxShadow: const [BoxShadow(color: Color(0x0D000000), blurRadius: 8, offset: Offset(0, 2))],
BoxShadow(
color: Color(0x0D000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
), ),
child: Row( child: Row(
children: [ children: [
@@ -217,53 +603,26 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF86909C))),
label,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF86909C),
),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Color(0xFF1D2129))),
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
),
], ],
), ),
); );
} }
/// AI识别结果
Widget _buildAIResults() { Widget _buildAIResults() {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: const [ boxShadow: const [BoxShadow(color: Color(0x0D000000), blurRadius: 8, offset: Offset(0, 2))],
BoxShadow(
color: Color(0x0D000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text('AI 识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1D2129))),
'AI 识别结果',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildAIResultItem('热斑疑似', '3 处'), _buildAIResultItem('热斑疑似', '3 处'),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -278,128 +637,51 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
onTap: () {}, onTap: () {},
child: Row( child: Row(
children: [ children: [
const Icon( const Icon(Icons.warning_amber_rounded, color: Color(0xFFFF7D00), size: 20),
Icons.warning_amber_rounded,
color: Color(0xFFFF7D00),
size: 20,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(child: Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)))),
child: Text( Text(count, style: const TextStyle(fontSize: 14, color: Color(0xFF4E5969))),
label,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1D2129),
),
),
),
Text(
count,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF4E5969),
),
),
const SizedBox(width: 4), const SizedBox(width: 4),
const Icon( const Icon(Icons.chevron_right, color: Color(0xFF86909C), size: 20),
Icons.chevron_right,
color: Color(0xFF86909C),
size: 20,
),
], ],
), ),
); );
} }
/// 地图和摇杆
Widget _buildMapAndJoystick() { Widget _buildMapAndJoystick() {
return Row( return Row(
children: [ children: [
// 地图
Expanded( Expanded(
child: Container( child: Container(
height: 160, height: 160,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: const [ boxShadow: const [BoxShadow(color: Color(0x0D000000), blurRadius: 8, offset: Offset(0, 2))],
BoxShadow(
color: Color(0x0D000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Image.asset( child: Image.asset('assets/images/xunjian.png', fit: BoxFit.cover, width: double.infinity),
'assets/images/xunjian.png',
fit: BoxFit.cover,
width: double.infinity,
),
), ),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// 摇杆控制器
Expanded( Expanded(
child: Container( child: Container(
height: 160, height: 160,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: const [ boxShadow: const [BoxShadow(color: Color(0x0D000000), blurRadius: 8, offset: Offset(0, 2))],
BoxShadow(
color: Color(0x0D000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
), ),
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
// 中心圆点 Container(width: 40, height: 40, decoration: BoxDecoration(color: const Color(0xFFC9CDD4), shape: BoxShape.circle)),
Container( Positioned(top: 16, child: IconButton(icon: const Icon(Icons.arrow_drop_up, size: 32, color: Color(0xFF4E5969)), onPressed: () {})),
width: 40, Positioned(bottom: 16, child: IconButton(icon: const Icon(Icons.arrow_drop_down, size: 32, color: Color(0xFF4E5969)), onPressed: () {})),
height: 40, Positioned(left: 16, child: IconButton(icon: const Icon(Icons.arrow_left, size: 32, color: Color(0xFF4E5969)), onPressed: () {})),
decoration: BoxDecoration( Positioned(right: 16, child: IconButton(icon: const Icon(Icons.arrow_right, size: 32, color: Color(0xFF4E5969)), onPressed: () {})),
color: const Color(0xFFC9CDD4),
shape: BoxShape.circle,
),
),
// 上
Positioned(
top: 16,
child: IconButton(
icon: const Icon(Icons.arrow_drop_up, size: 32, color: Color(0xFF4E5969)),
onPressed: () {},
),
),
// 下
Positioned(
bottom: 16,
child: IconButton(
icon: const Icon(Icons.arrow_drop_down, size: 32, color: Color(0xFF4E5969)),
onPressed: () {},
),
),
// 左
Positioned(
left: 16,
child: IconButton(
icon: const Icon(Icons.arrow_left, size: 32, color: Color(0xFF4E5969)),
onPressed: () {},
),
),
// 右
Positioned(
right: 16,
child: IconButton(
icon: const Icon(Icons.arrow_right, size: 32, color: Color(0xFF4E5969)),
onPressed: () {},
),
),
], ],
), ),
), ),
@@ -408,20 +690,13 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
); );
} }
/// 底部工具栏
Widget _buildBottomToolbar() { Widget _buildBottomToolbar() {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: const [ boxShadow: const [BoxShadow(color: Color(0x0D000000), blurRadius: 8, offset: Offset(0, 2))],
BoxShadow(
color: Color(0x0D000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
@@ -442,15 +717,10 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
children: [ children: [
Icon(icon, size: 28, color: const Color(0xFF4E5969)), Icon(icon, size: 28, color: const Color(0xFF4E5969)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF4E5969))),
label,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF4E5969),
),
),
], ],
), ),
); );
} }
} }

View File

@@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../core/di/injection.dart';
import '../../domain/entities/uav_video_stream_entity.dart';
import '../bloc/drone_station_bloc.dart';
import '../bloc/drone_station_event.dart';
import '../bloc/drone_station_state.dart';
/// 无人机实时视频监控页面(使用新的 changeLens 接口)
class UavLiveVideoPage extends StatefulWidget {
final String droneSn; // 无人机设备序列号
final String cameraIndex; // 摄像头编号
const UavLiveVideoPage({
super.key,
required this.droneSn,
required this.cameraIndex,
});
@override
State<UavLiveVideoPage> createState() => _UavLiveVideoPageState();
}
class _UavLiveVideoPageState extends State<UavLiveVideoPage> {
late DroneStationBloc _bloc;
UavVideoStreamEntity? _videoStream;
bool _isLoading = false;
String? _errorMessage;
UavLensType? _currentLensType;
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
// 默认加载广角镜头
_loadVideoStream(UavLensType.wide);
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
/// 加载视频流
void _loadVideoStream(UavLensType lensType) {
setState(() {
_isLoading = true;
_errorMessage = null;
_currentLensType = lensType;
});
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: lensType,
qualityType: VideoQualityType.adaptive,
videoExpire: 720000000,
),
);
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _bloc,
child: Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'无人机实时视频',
style: TextStyle(color: Colors.white),
),
centerTitle: true,
actions: [
// 镜头切换按钮
PopupMenuButton<UavLensType>(
icon: const Icon(Icons.videocam, color: Colors.white),
tooltip: '切换镜头',
onSelected: (lensType) {
_loadVideoStream(lensType);
},
itemBuilder: (context) => [
const PopupMenuItem(
value: UavLensType.wide,
child: Row(
children: [
Icon(Icons.videocam, size: 20),
SizedBox(width: 8),
Text('广角镜头'),
],
),
),
const PopupMenuItem(
value: UavLensType.zoom,
child: Row(
children: [
Icon(Icons.zoom_in, size: 20),
SizedBox(width: 8),
Text('变焦镜头'),
],
),
),
const PopupMenuItem(
value: UavLensType.ir,
child: Row(
children: [
Icon(Icons.thermostat, size: 20),
SizedBox(width: 8),
Text('红外镜头'),
],
),
),
],
),
],
),
body: BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
if (state is UavVideoStreamLoaded) {
setState(() {
_videoStream = state.videoStream;
_isLoading = false;
});
debugPrint('=== 视频流加载成功 ===');
debugPrint('URL Type: ${state.videoStream.urlType}');
debugPrint('AppId: ${state.videoStream.appId}');
debugPrint('RoomId: ${state.videoStream.roomId}');
debugPrint('UserId: ${state.videoStream.userId}');
// TODO: 这里可以初始化 RTC 引擎并显示视频
} else if (state is UavVideoStreamError) {
setState(() {
_errorMessage = state.message;
_isLoading = false;
});
}
},
builder: (context, state) {
if (_isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16),
Text(
'正在加载视频流...',
style: TextStyle(color: Colors.white),
),
],
),
);
}
if (_errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(
_errorMessage!,
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
if (_currentLensType != null) {
_loadVideoStream(_currentLensType!);
}
},
child: const Text('重试'),
),
],
),
);
}
if (_videoStream == null) {
return const Center(
child: Text(
'暂无视频信号',
style: TextStyle(color: Colors.white),
),
);
}
// TODO: 这里根据 _videoStream 的 urlType 初始化对应的 RTC 引擎
// - 如果是 volc,使用火山引擎 RTC SDK
// - 如果是 agora,使用声网 RTC SDK
return Container(
width: double.infinity,
height: double.infinity,
color: Colors.black,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.videocam_off,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
'视频流已获取\nURL Type: ${_videoStream!.urlType}\nCamera: $_currentLensType',
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'请集成 RTC SDK 后在此处显示视频画面',
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
),
),
],
),
),
);
},
),
),
);
}
}

View File

@@ -3,7 +3,9 @@ import '../pages/drone_mission_control_page.dart';
/// 无人机机场与设备状态组件 /// 无人机机场与设备状态组件
class DroneStationStatusWidget extends StatelessWidget { class DroneStationStatusWidget extends StatelessWidget {
const DroneStationStatusWidget({super.key}); final String? droneSn; // 无人机序列号
const DroneStationStatusWidget({super.key, this.droneSn});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -220,7 +222,8 @@ class DroneStationStatusWidget extends StatelessWidget {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => const DroneMissionControlPage(), builder: (context) =>
DroneMissionControlPage(droneSn: droneSn),
), ),
); );
}, },
@@ -252,19 +255,12 @@ class DroneStationStatusWidget extends StatelessWidget {
color: color.withOpacity(0.1), color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Icon( child: Icon(icon, color: color, size: 24),
icon,
color: color,
size: 24,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
label, label,
style: const TextStyle( style: const TextStyle(fontSize: 12, color: Color(0xFF4E5969)),
fontSize: 12,
color: Color(0xFF4E5969),
),
), ),
], ],
), ),
@@ -277,10 +273,7 @@ class DroneStationStatusWidget extends StatelessWidget {
children: [ children: [
Text( Text(
label, label,
style: const TextStyle( style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)),
fontSize: 13,
color: Color(0xFF86909C),
),
), ),
const Spacer(), const Spacer(),
Text( 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( return Row(
children: [ children: [
Text( Text(
label, label,
style: const TextStyle( style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)),
fontSize: 13,
color: Color(0xFF86909C),
),
), ),
const Spacer(), const Spacer(),
Text( Text(
@@ -343,11 +337,7 @@ class DroneStationStatusWidget extends StatelessWidget {
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
const Icon( const Icon(Icons.arrow_forward_ios, size: 12, color: Color(0xFF86909C)),
Icons.arrow_forward_ios,
size: 12,
color: Color(0xFF86909C),
),
], ],
); );
} }
@@ -358,10 +348,7 @@ class DroneStationStatusWidget extends StatelessWidget {
children: [ children: [
const Text( const Text(
'气象条件', '气象条件',
style: TextStyle( style: TextStyle(fontSize: 13, color: Color(0xFF86909C)),
fontSize: 13,
color: Color(0xFF86909C),
),
), ),
const Spacer(), const Spacer(),
Row( Row(
@@ -379,18 +366,12 @@ class DroneStationStatusWidget extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
const Text( const Text(
'25°C', '25°C',
style: TextStyle( style: TextStyle(fontSize: 13, color: Color(0xFF1D2129)),
fontSize: 13,
color: Color(0xFF1D2129),
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text( const Text(
'东南风 2级', '东南风 2级',
style: TextStyle( style: TextStyle(fontSize: 13, color: Color(0xFF1D2129)),
fontSize: 13,
color: Color(0xFF1D2129),
),
), ),
], ],
), ),
@@ -404,10 +385,7 @@ class DroneStationStatusWidget extends StatelessWidget {
children: [ children: [
const Text( const Text(
'电量', '电量',
style: TextStyle( style: TextStyle(fontSize: 13, color: Color(0xFF86909C)),
fontSize: 13,
color: Color(0xFF86909C),
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
@@ -416,7 +394,9 @@ class DroneStationStatusWidget extends StatelessWidget {
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: 0.78, value: 0.78,
backgroundColor: const Color(0xFFF2F3F5), backgroundColor: const Color(0xFFF2F3F5),
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF00B42A)), valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFF00B42A),
),
minHeight: 6, minHeight: 6,
), ),
), ),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../../../core/consts/http_api_consts.dart'; import '../../../../../core/consts/http_api_consts.dart';
import '../../domain/entities/flight_task_entity.dart'; import '../../domain/entities/flight_task_entity.dart';
import '../pages/create_task_page.dart';
class FlightTaskSelectorModal extends StatefulWidget { class FlightTaskSelectorModal extends StatefulWidget {
final String currentGatewaySn; final String currentGatewaySn;
@@ -40,16 +41,49 @@ class _FlightTaskSelectorModalState extends State<FlightTaskSelectorModal> {
} }
Future<void> _selectDateRange(BuildContext context) async { Future<void> _selectDateRange(BuildContext context) async {
final picked = await showDateRangePicker( // 先选择日期范围
final pickedDate = await showDateRangePicker(
context: context, context: context,
firstDate: DateTime(2020), firstDate: DateTime(2020),
lastDate: DateTime(2030), lastDate: DateTime(2030),
initialDateRange: _selectedDateRange, initialDateRange: _selectedDateRange,
); );
if (picked != null) {
setState(() { if (pickedDate != null) {
_selectedDateRange = picked; // 选择开始时间(时分秒)
}); 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<FlightTaskSelectorModal> {
data.forEach((sn, value) { data.forEach((sn, value) {
if (value != null && value['list'] != null) { if (value != null && value['list'] != null) {
final list = value['list'] as List<dynamic>; final list = value['list'] as List<dynamic>;
print(' [FlightTask] API返回的原始数据: $list');
result[sn] = list.map((item) => FlightTaskEntity.fromJson(item)).toList(); result[sn] = list.map((item) => FlightTaskEntity.fromJson(item)).toList();
} else { } else {
result[sn] = null; result[sn] = null;
@@ -129,15 +164,30 @@ class _FlightTaskSelectorModalState extends State<FlightTaskSelectorModal> {
const SizedBox(height: 12), const SizedBox(height: 12),
_buildDeviceSelector(), _buildDeviceSelector(),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( Row(
onPressed: _isLoading ? null : _loadTasks, children: [
style: ElevatedButton.styleFrom( Expanded(
backgroundColor: const Color(0xFF165DFF), child: ElevatedButton(
minimumSize: const Size(double.infinity, 48), onPressed: _isLoading ? null : _loadTasks,
), style: ElevatedButton.styleFrom(
child: _isLoading backgroundColor: const Color(0xFF165DFF),
? const CircularProgressIndicator(color: Colors.white) minimumSize: const Size(double.infinity, 48),
: const Text('查询任务'), ),
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) ...[ if (_errorMessage != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -173,7 +223,7 @@ class _FlightTaskSelectorModalState extends State<FlightTaskSelectorModal> {
Expanded( Expanded(
child: Text( child: Text(
_selectedDateRange != null _selectedDateRange != null
? '${_formatDate(_selectedDateRange!.start)} - ${_formatDate(_selectedDateRange!.end)}' ? '${_formatDateTimeFull(_selectedDateRange!.start)} -\n${_formatDateTimeFull(_selectedDateRange!.end)}'
: '选择时间范围', : '选择时间范围',
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
@@ -307,6 +357,11 @@ class _FlightTaskSelectorModalState extends State<FlightTaskSelectorModal> {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; 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) { String _formatDateTime(String dateTimeStr) {
try { try {
final dateTime = DateTime.parse(dateTimeStr); final dateTime = DateTime.parse(dateTimeStr);
@@ -315,4 +370,15 @@ class _FlightTaskSelectorModalState extends State<FlightTaskSelectorModal> {
return dateTimeStr; return dateTimeStr;
} }
} }
}
// 显示创建任务对话框
void _showCreateTaskDialog() {
Navigator.pop(context); // 先关闭当前模态页
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CreateTaskPage(sn: widget.currentGatewaySn),
),
);
}
}