1优化改动设备栏中全部标签栏的设备列表中数据的整合,又数据合并解耦成使用已有组件各自维护,使其样式和逻辑一致。

2接入了无人机机场的实时信息和无人机的实时信息,并创建了对应的组件,并测试。
3.实现了机器人的详情页的视频显示的逻辑为当机器人在线,就在下方同步出来机器人的各个方位视频,并且支持切换。
4.实现了全部设备中对类型的区分和实现了点击进入对应的详情页。
5.修复了部分bug,优化了代码结构,提高了代码的可读性和可维护性。
6更改了机器人列表项中的信息展示为机器序列号和别名和id(优先展示别名)
7.更改了无人机场的信息展示上位 无人机及序列号下位机场序列号。
8优化了无人机机场信息只能是的为明文展示。便于观看理解。
This commit is contained in:
2026-06-23 16:20:51 +08:00
parent 06718ed957
commit b7ea788298
25 changed files with 3074 additions and 309 deletions

View File

@@ -192,11 +192,14 @@ Future<void> init() async {
sl.registerLazySingleton<ILoggerService>(() => SentryLoggerImpl());
/// 1.4 --- MQTT Data Sources ---
sl.registerLazySingleton<DroneOsdDataSource>(
() => DroneOsdDataSourceImpl(sl<MqttClient>(instanceName: 'droneOsdClient')),
sl.registerFactory<DroneOsdDataSource>(
() =>
DroneOsdDataSourceImpl(sl<MqttClient>(instanceName: 'droneOsdClient')),
);
sl.registerLazySingleton<TaskMessageDataSource>(
() => TaskMessageDataSourceImpl(sl<MqttClient>(instanceName: 'taskMessageClient')),
sl.registerFactory<TaskMessageDataSource>(
() => TaskMessageDataSourceImpl(
sl<MqttClient>(instanceName: 'taskMessageClient'),
),
);
/// 1.5 --- MQTT Repositories ---
@@ -492,9 +495,7 @@ Future<void> init() async {
);
/// 创建设备任务(通过接口执行作业)
sl.registerLazySingleton(
() => CreateDeviceTaskUseCase(sl<PathRepository>()),
);
sl.registerLazySingleton(() => CreateDeviceTaskUseCase(sl<PathRepository>()));
/// 6. 认证 (Auth)
// --- 关键修改点 1: AuthCubit 必须在 GoRouter 之前注册,并传入参数 ---
@@ -546,12 +547,5 @@ Future<void> init() async {
sl.registerLazySingleton(() => CancelTaskUseCase(sl()));
sl.registerLazySingleton(() => PauseTaskUseCase(sl()));
sl.registerLazySingleton(() => RecoveryTaskUseCase(sl()));
sl.registerFactory(
() => DeviceTaskCubit(
sl(),
sl(),
sl(),
sl(),
),
);
sl.registerFactory(() => DeviceTaskCubit(sl(), sl(), sl(), sl()));
}

View File

@@ -40,6 +40,17 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
required String deviceSn,
required String gatewaySn,
}) async {
if (_deviceSn == deviceSn &&
_gatewaySn == gatewaySn &&
_subscription != null) {
debugPrint('[DroneOsdDataSource] already listening');
return;
}
if (_deviceSn != null || _gatewaySn != null) {
await stopListening();
}
_deviceSn = deviceSn;
_gatewaySn = gatewaySn;
@@ -50,8 +61,12 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
debugPrint(' 无人机: $droneTopic');
debugPrint(' 机场: $stationTopic');
await mqttClient.subscribe(droneTopic);
await mqttClient.subscribe(stationTopic);
if (deviceSn.isNotEmpty) {
await mqttClient.subscribe(droneTopic);
}
if (gatewaySn.isNotEmpty) {
await mqttClient.subscribe(stationTopic);
}
_subscription = mqttClient.messageStream?.listen((message) {
_handleMessage(message);
@@ -63,11 +78,13 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
await _subscription?.cancel();
_subscription = null;
if (_deviceSn != null) {
await mqttClient.unsubscribe('thing/product/$_deviceSn/osd');
if (_deviceSn != null && _deviceSn!.isNotEmpty) {
final deviceSn = _deviceSn!;
await mqttClient.unsubscribe('thing/product/$deviceSn/osd');
}
if (_gatewaySn != null) {
await mqttClient.unsubscribe('thing/product/$_gatewaySn/osd');
if (_gatewaySn != null && _gatewaySn!.isNotEmpty) {
final gatewaySn = _gatewaySn!;
await mqttClient.unsubscribe('thing/product/$gatewaySn/osd');
}
_deviceSn = null;
@@ -79,12 +96,22 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
final jsonData = jsonDecode(message.payload) as Map<String, dynamic>;
final osdData = DroneOsdEntity.fromJson(jsonData);
if (message.topic.contains(_deviceSn ?? '')) {
debugPrint('🛸 [DroneOsdDataSource] 无人机 OSD 更新');
_droneOsdController.add(osdData);
} else if (message.topic.contains(_gatewaySn ?? '')) {
// 🔥 重要修复:先判断 gatewaySn,再判断 deviceSn
// 因为 topic 可能同时包含两者,但我们需要优先匹配机场
if (_gatewaySn != null &&
_gatewaySn!.isNotEmpty &&
message.topic.contains(_gatewaySn!)) {
debugPrint('🏢 [DroneOsdDataSource] 机场 OSD 更新');
debugPrint('🏢 [DroneOsdDataSource] Topic: ${message.topic}');
_stationOsdController.add(osdData);
} else if (_deviceSn != null &&
_deviceSn!.isNotEmpty &&
message.topic.contains(_deviceSn!)) {
debugPrint('🛸 [DroneOsdDataSource] 无人机 OSD 更新');
debugPrint('🛸 [DroneOsdDataSource] Topic: ${message.topic}');
_droneOsdController.add(osdData);
} else {
debugPrint('⚠️ [DroneOsdDataSource] 未知设备类型,Topic: ${message.topic}');
}
} catch (e) {
debugPrint('❌ [DroneOsdDataSource] 解析 OSD 数据失败: $e');

View File

@@ -21,7 +21,8 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
final MqttClient mqttClient;
final _taskStatusController = StreamController<TaskStatusEntity>.broadcast();
final _taskArriveController = StreamController<TaskArriveEntity>.broadcast();
final _realTimeMessageController = StreamController<RealTimeMessageEntity>.broadcast();
final _realTimeMessageController =
StreamController<RealTimeMessageEntity>.broadcast();
StreamSubscription<MqttMessage>? _subscription;
String? _deviceId;
@@ -40,6 +41,18 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
@override
Future<void> startListening({required String deviceId}) async {
if (_deviceId == deviceId && _subscription != null) {
debugPrint('[TaskMessageDataSource] already listening: $deviceId');
return;
}
if (_deviceId != null && _deviceId != deviceId) {
await stopListening();
} else {
await _subscription?.cancel();
_subscription = null;
}
_deviceId = deviceId;
final taskStatusTopic = 'task/$deviceId/status';
@@ -66,9 +79,10 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
_subscription = null;
if (_deviceId != null) {
await mqttClient.unsubscribe('task/$_deviceId/status');
await mqttClient.unsubscribe('task/$_deviceId/arrive');
await mqttClient.unsubscribe('device/$_deviceId/realTimeMessage');
final deviceId = _deviceId!;
await mqttClient.unsubscribe('task/$deviceId/status');
await mqttClient.unsubscribe('task/$deviceId/arrive');
await mqttClient.unsubscribe('device/$deviceId/realTimeMessage');
}
_deviceId = null;
@@ -88,7 +102,9 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
_taskArriveController.add(arriveInfo);
} else if (message.topic.contains('/realTimeMessage')) {
final realTimeMsg = RealTimeMessageEntity.fromJson(jsonData);
debugPrint('💬 [TaskMessageDataSource] 实时消息 - 类型: ${realTimeMsg.type}, 数据点数: ${realTimeMsg.data.length}');
debugPrint(
'💬 [TaskMessageDataSource] 实时消息 - 类型: ${realTimeMsg.type}, 数据点数: ${realTimeMsg.data.length}',
);
_realTimeMessageController.add(realTimeMsg);
}
} catch (e) {

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:mqtt_client/mqtt_client.dart' as mqtt;
import 'package:mqtt_client/mqtt_server_client.dart' as mqtt_server;
@@ -10,8 +11,14 @@ import '../../domain/models/mqtt_message.dart';
class MqttClientImpl implements MqttClient {
mqtt_server.MqttServerClient? _client;
final _messageController = StreamController<MqttMessage>.broadcast();
final Map<String, int> _subscriptionRefs = <String, int>{};
MqttConfig? _currentConfig;
StreamSubscription? _updatesSubscription;
Timer? _reconnectTimer;
bool _isConnected = false;
bool _isConnecting = false;
bool _manualDisconnect = false;
@override
Stream<MqttMessage>? get messageStream => _messageController.stream;
@@ -21,148 +28,239 @@ class MqttClientImpl implements MqttClient {
@override
Future<void> connect(MqttConfig config) async {
if (_isConnecting) {
debugPrint('[MqttClient] connect ignored, already connecting');
return;
}
if (_isConnected) {
debugPrint('⚠️ [MqttClient] 已连接,先断开');
debugPrint('[MqttClient] already connected, disconnect first');
await disconnect();
}
_manualDisconnect = false;
_currentConfig = config;
_reconnectTimer?.cancel();
_reconnectTimer = null;
_isConnecting = true;
try {
_currentConfig = config;
final clientId = '${config.clientId}_${DateTime.now().millisecondsSinceEpoch}';
switch (config.protocol) {
case MqttProtocol.websocket:
_client = mqtt_server.MqttServerClient.withPort(
config.host,
clientId,
config.port,
);
_client!.useWebSocket = true;
break;
case MqttProtocol.wss:
_client = mqtt_server.MqttServerClient.withPort(
config.host,
clientId,
config.port,
);
_client!.useWebSocket = true;
_client!.secure = true;
break;
case MqttProtocol.tcp:
default:
_client = mqtt_server.MqttServerClient.withPort(
config.host,
clientId,
config.port,
);
break;
}
_client!.logging(on: false);
_client!.keepAlivePeriod = config.keepAlivePeriod;
_client!.autoReconnect = true;
_client!.resubscribeOnAutoReconnect = true;
_client!.onDisconnected = _onDisconnected;
_client!.onConnected = _onConnected;
_client!.onSubscribed = _onSubscribed;
final connMessage = mqtt.MqttConnectMessage()
.withClientIdentifier(clientId)
.startClean()
.withWillQos(mqtt.MqttQos.atLeastOnce);
_client!.connectionMessage = connMessage;
debugPrint('🔌 [MqttClient] 开始连接到 ${config.host}:${config.port}');
await _updatesSubscription?.cancel();
_updatesSubscription = null;
_client = _createClient(config);
_configureClient(_client!, config);
debugPrint('[MqttClient] connecting to ${config.connectionAddress}');
await _client!.connect(config.username, config.password);
if (_client!.connectionStatus?.state == mqtt.MqttConnectionState.connected) {
if (_client!.connectionStatus?.state ==
mqtt.MqttConnectionState.connected) {
_isConnected = true;
debugPrint('✅ [MqttClient] 连接成功');
debugPrint('[MqttClient] connected');
_listenToMessages();
_resubscribeAll();
} else {
throw Exception('连接失败: ${_client!.connectionStatus?.returnCode}');
throw Exception(
'MQTT connect failed: ${_client!.connectionStatus?.returnCode}',
);
}
} catch (e) {
debugPrint('❌ [MqttClient] 连接异常: $e');
debugPrint('[MqttClient] connect error: $e');
_isConnected = false;
_isConnecting = false;
_scheduleReconnect();
rethrow;
} finally {
_isConnecting = false;
}
}
mqtt_server.MqttServerClient _createClient(MqttConfig config) {
final clientId =
'${config.clientId}_${DateTime.now().millisecondsSinceEpoch}';
switch (config.protocol) {
case MqttProtocol.websocket:
case MqttProtocol.wss:
final client = mqtt_server.MqttServerClient.withPort(
config.connectionAddress,
clientId,
config.port,
);
client.useWebSocket = true;
client.websocketProtocols = ['mqtt'];
return client;
case MqttProtocol.tcp:
return mqtt_server.MqttServerClient.withPort(
config.host,
clientId,
config.port,
);
}
}
void _configureClient(
mqtt_server.MqttServerClient client,
MqttConfig config,
) {
client.logging(on: false);
client.keepAlivePeriod = config.keepAlivePeriod;
client.autoReconnect = false;
client.resubscribeOnAutoReconnect = false;
client.onDisconnected = _onDisconnected;
client.onConnected = _onConnected;
client.onSubscribed = _onSubscribed;
var connMessage = mqtt.MqttConnectMessage()
.withClientIdentifier(client.clientIdentifier)
.withWillQos(mqtt.MqttQos.atLeastOnce);
if (config.cleanSession) {
connMessage = connMessage.startClean();
}
client.connectionMessage = connMessage;
}
@override
Future<void> disconnect() async {
debugPrint('🔌 [MqttClient] 断开连接');
debugPrint('[MqttClient] disconnect');
_manualDisconnect = true;
_reconnectTimer?.cancel();
_reconnectTimer = null;
await _updatesSubscription?.cancel();
_updatesSubscription = null;
_client?.disconnect();
_client = null;
_isConnected = false;
_isConnecting = false;
_currentConfig = null;
}
@override
Future<void> subscribe(String topic) async {
final previousRefs = _subscriptionRefs[topic] ?? 0;
_subscriptionRefs[topic] = previousRefs + 1;
if (!_isConnected || _client == null) {
throw Exception('MQTT 未连接');
debugPrint('[MqttClient] queued subscription while disconnected: $topic');
_scheduleReconnect();
return;
}
debugPrint('📡 [MqttClient] 订阅主题: $topic');
if (previousRefs > 0) {
debugPrint('[MqttClient] subscription already active: $topic');
return;
}
debugPrint('[MqttClient] subscribe: $topic');
_client!.subscribe(topic, mqtt.MqttQos.atLeastOnce);
}
@override
Future<void> unsubscribe(String topic) async {
if (!_isConnected || _client == null) {
throw Exception('MQTT 未连接');
final currentRefs = _subscriptionRefs[topic] ?? 0;
if (currentRefs <= 1) {
_subscriptionRefs.remove(topic);
} else {
_subscriptionRefs[topic] = currentRefs - 1;
debugPrint('[MqttClient] subscription still in use: $topic');
return;
}
debugPrint('🔕 [MqttClient] 取消订阅: $topic');
if (!_isConnected || _client == null) {
debugPrint('[MqttClient] removed queued subscription: $topic');
return;
}
debugPrint('[MqttClient] unsubscribe: $topic');
_client!.unsubscribe(topic);
}
@override
Future<void> publish(String topic, String message) async {
if (!_isConnected || _client == null) {
throw Exception('MQTT 未连接');
throw Exception('MQTT is not connected');
}
debugPrint('📤 [MqttClient] 发布消息到 $topic');
debugPrint('[MqttClient] publish: $topic');
final builder = mqtt.MqttClientPayloadBuilder();
builder.addString(message);
_client!.publishMessage(topic, mqtt.MqttQos.atLeastOnce, builder.payload!);
}
void _listenToMessages() {
_client!.updates!.listen((List<mqtt.MqttReceivedMessage<mqtt.MqttMessage>> messages) {
for (final msg in messages) {
final topic = msg.topic;
final payload = mqtt.MqttPublishPayload.bytesToStringAsString((msg.payload as mqtt.MqttPublishMessage).payload.message);
_updatesSubscription = _client!.updates!.listen(
(List<mqtt.MqttReceivedMessage<mqtt.MqttMessage>> messages) {
for (final msg in messages) {
final topic = msg.topic;
final payload = mqtt.MqttPublishPayload.bytesToStringAsString(
(msg.payload as mqtt.MqttPublishMessage).payload.message,
);
debugPrint('📥 [MqttClient] 收到消息 [$topic]: $payload');
debugPrint('[MqttClient] received [$topic]: $payload');
_messageController.add(MqttMessage(topic: topic, payload: payload));
}
},
onError: (Object error) {
debugPrint('[MqttClient] updates stream error: $error');
_isConnected = false;
_scheduleReconnect();
},
cancelOnError: false,
);
}
_messageController.add(MqttMessage(
topic: topic,
payload: payload,
));
}
});
void _resubscribeAll() {
if (_subscriptionRefs.isEmpty || _client == null) return;
for (final topic in _subscriptionRefs.keys) {
debugPrint('[MqttClient] resubscribe: $topic');
_client!.subscribe(topic, mqtt.MqttQos.atLeastOnce);
}
}
void _onConnected() {
debugPrint('✅ [MqttClient] 已连接');
debugPrint('[MqttClient] connected callback');
_isConnected = true;
}
void _onDisconnected() {
debugPrint('❌ [MqttClient] 已断开');
debugPrint('[MqttClient] disconnected callback');
_isConnected = false;
if (!_manualDisconnect) {
_scheduleReconnect();
}
}
void _onSubscribed(String topic) {
debugPrint('✅ [MqttClient] 订阅成功: $topic');
debugPrint('[MqttClient] subscribed: $topic');
}
void _scheduleReconnect() {
final config = _currentConfig;
if (_manualDisconnect || config == null || _isConnected || _isConnecting) {
return;
}
if (_reconnectTimer?.isActive ?? false) return;
final delay = Duration(milliseconds: config.reconnectDelayMs);
debugPrint('[MqttClient] reconnect scheduled in ${delay.inMilliseconds}ms');
_reconnectTimer = Timer(delay, () async {
if (_manualDisconnect || _isConnected || _isConnecting) return;
try {
debugPrint('[MqttClient] reconnecting...');
await connect(config);
} catch (e) {
debugPrint('[MqttClient] reconnect failed: $e');
}
});
}
void dispose() {
_messageController.close();
disconnect();
_messageController.close();
}
}

View File

@@ -1,6 +1,16 @@
import 'package:equatable/equatable.dart';
import 'package:equatable/equatable.dart';
enum MqttProtocol { tcp, websocket, wss }
/// MQTT 传输协议类型
enum MqttProtocol {
/// 纯 TCP 连接(原生 MQTT)
tcp,
/// WebSocket 连接(MQTT over WebSocket)
websocket,
/// 加密的 WebSocket 连接(MQTT over WSS)
wss,
}
class MqttConfig extends Equatable {
final String host;
@@ -12,6 +22,9 @@ class MqttConfig extends Equatable {
final bool cleanSession;
final int keepAlivePeriod;
final int reconnectDelayMs;
/// WebSocket 路径(仅 WebSocket 模式使用)
final String? wsPath;
const MqttConfig({
required this.host,
@@ -23,8 +36,23 @@ class MqttConfig extends Equatable {
this.cleanSession = true,
this.keepAlivePeriod = 60,
this.reconnectDelayMs = 3000,
this.wsPath,
});
/// 获取完整的连接地址
String get connectionAddress {
switch (protocol) {
case MqttProtocol.websocket:
return 'ws://$host:$port${wsPath ?? '/mqtt'}';
case MqttProtocol.wss:
return 'wss://$host:$port${wsPath ?? '/mqtt'}';
case MqttProtocol.tcp:
default:
return host;
}
}
/// 无人机/机场 OSD 数据(WebSocket MQTT)
factory MqttConfig.droneOsd() {
return const MqttConfig(
host: '1.95.137.212',
@@ -33,11 +61,13 @@ class MqttConfig extends Equatable {
password: 'jsmbzn520',
protocol: MqttProtocol.websocket,
clientId: 'drone_osd_client',
wsPath: '/mqtt',
cleanSession: true,
reconnectDelayMs: 3000,
);
}
/// 任务状态消息(TCP MQTT)
factory MqttConfig.taskMessage() {
return const MqttConfig(
host: '1.95.137.212',
@@ -61,6 +91,7 @@ class MqttConfig extends Equatable {
bool? cleanSession,
int? keepAlivePeriod,
int? reconnectDelayMs,
String? wsPath,
}) {
return MqttConfig(
host: host ?? this.host,
@@ -72,6 +103,7 @@ class MqttConfig extends Equatable {
cleanSession: cleanSession ?? this.cleanSession,
keepAlivePeriod: keepAlivePeriod ?? this.keepAlivePeriod,
reconnectDelayMs: reconnectDelayMs ?? this.reconnectDelayMs,
wsPath: wsPath ?? this.wsPath,
);
}
@@ -86,5 +118,6 @@ class MqttConfig extends Equatable {
cleanSession,
keepAlivePeriod,
reconnectDelayMs,
wsPath,
];
}

View File

@@ -20,20 +20,31 @@ class MqttManager {
return;
}
debugPrint('🔧 [MqttManager] 开始初始化 MQTT 连接...');
final droneOsdClient = sl<MqttClient>(instanceName: 'droneOsdClient');
final taskMessageClient = sl<MqttClient>(instanceName: 'taskMessageClient');
// 独立连接两个 MQTT 客户端,互不影响
await _connectClient(droneOsdClient, MqttConfig.droneOsd(), 'droneOsdClient');
await _connectClient(taskMessageClient, MqttConfig.taskMessage(), 'taskMessageClient');
_isInitialized = true;
debugPrint('✅ [MqttManager] MQTT 初始化完成');
}
Future<void> _connectClient(
MqttClient client,
MqttConfig config,
String clientName,
) async {
try {
debugPrint('🔧 [MqttManager] 开始初始化 MQTT 连接...');
final droneOsdClient = sl<MqttClient>(instanceName: 'droneOsdClient');
final taskMessageClient = sl<MqttClient>(instanceName: 'taskMessageClient');
await droneOsdClient.connect(MqttConfig.droneOsd());
await taskMessageClient.connect(MqttConfig.taskMessage());
_isInitialized = true;
debugPrint('✅ [MqttManager] MQTT 初始化完成');
debugPrint('🔌 [MqttManager] 连接 $clientName...');
await client.connect(config);
debugPrint('✅ [MqttManager] $clientName 连接成功');
} catch (e) {
debugPrint('❌ [MqttManager] MQTT 初始化失败: $e');
rethrow;
debugPrint('❌ [MqttManager] $clientName 连接失败: $e');
// 不抛出异常,允许其他客户端继续连接
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
import 'package:maibu_satabot_v2/core/router/route_paths.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart';
@@ -11,6 +12,7 @@ import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_stat
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_state.dart';
import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_cubit.dart'; // 🔥 添加 RemoteControlCubit
import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart';
class MachineDetailsPage extends StatefulWidget {
final DeviceEntity? device;
@@ -34,6 +36,19 @@ class _MachineDetailsPageState extends State<MachineDetailsPage> {
// 🔥 保存 stream 引用,避免每次 rebuild 都重新获取
late Stream<DeviceStatusState> _deviceStatusStream;
late DeviceStatusState _initialState;
// 🔥 新增:视频相关状态
String _videoStreamUrl = '';
int _currentViewIndex = 0; // 0=前, 1=后, 2=左, 3=右, 4=上
// 视角配置
final List<Map<String, dynamic>> _viewConfigs = [
{'name': '前视', 'alignment': Alignment.topLeft, 'icon': Icons.arrow_upward},
{'name': '后视', 'alignment': Alignment.topRight, 'icon': Icons.arrow_downward},
{'name': '左视', 'alignment': Alignment.bottomLeft, 'icon': Icons.arrow_back},
{'name': '右视', 'alignment': Alignment.bottomRight, 'icon': Icons.arrow_forward},
{'name': '俯视', 'alignment': Alignment.center, 'icon': Icons.view_agenda},
];
@override
void initState() {
@@ -47,6 +62,30 @@ class _MachineDetailsPageState extends State<MachineDetailsPage> {
_deviceStatusStream = bloc.stream;
_initialState = bloc.state;
debugPrint('📦 [MachineDetails] initState - 初始状态: ${_initialState.runtimeType}');
// 🔥 初始化视频 URL
_initVideoUrl();
}
/// 🔥 初始化视频流 URL
void _initVideoUrl() {
final userState = context.read<AppUserCubit>().state;
debugPrint('🎬 [MachineDetails] 开始初始化视频URL');
debugPrint('🎬 [MachineDetails] deviceId: $_deviceId');
debugPrint('🎬 [MachineDetails] user: ${userState.user}');
debugPrint('🎬 [MachineDetails] token: ${userState.user?.token}');
if (_deviceId.isNotEmpty && userState.user != null && userState.user!.token != null) {
setState(() {
_videoStreamUrl = "webrtc://${TCPConsts.TCP_IP}/live/livestream/$_deviceId?token=${userState.user!.token}";
});
debugPrint('✅ [MachineDetails] 视频URL初始化成功: $_videoStreamUrl');
} else {
debugPrint('❌ [MachineDetails] 视频URL初始化失败');
debugPrint(' - deviceId.isEmpty: ${_deviceId.isEmpty}');
debugPrint(' - user == null: ${userState.user == null}');
debugPrint(' - token == null: ${userState.user?.token == null}');
}
}
@override
@@ -292,10 +331,9 @@ class _MachineDetailsPageState extends State<MachineDetailsPage> {
);
}
/// 设备状态卡片(保持不变)
/// 设备状态卡片(🔥 修改为视频展示 + 视角切换)
Widget _buildDeviceStatusCard(DeviceEntity device) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
@@ -303,24 +341,112 @@ class _MachineDetailsPageState extends State<MachineDetailsPage> {
),
child: Column(
children: [
Image.asset(
'assets/images/car.png',
width: 300,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.device_hub, size: 100, color: Colors.grey);
},
// 🔥 视频展示区域
AspectRatio(
aspectRatio: 16 / 9,
child: _videoStreamUrl.isNotEmpty
? WebRTCLocalPlayer(
streamUrl: _videoStreamUrl,
showLeftPip: false, // 不显示悬浮小窗
showRightPip: false,
isFrontMain: _currentViewIndex == 0, // 根据当前视角决定主画面
)
: Container(
color: Colors.black87,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.videocam_off, size: 64, color: Colors.white54),
const SizedBox(height: 16),
Text(
'视频未加载',
style: TextStyle(color: Colors.white70, fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'deviceId: $_deviceId',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
const SizedBox(height: 4),
Text(
'URL: ${_videoStreamUrl.isEmpty ? "空" : "已设置"}',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: device.isOnline ? const Color(0xFF00C853).withOpacity(0.1) : const Color(0xFF999999).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
// 🔥 在线状态标签
Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: device.isOnline ? const Color(0xFF00C853).withOpacity(0.1) : const Color(0xFF999999).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
device.isOnline ? AppLocalizations.of(context).translate('home.device_online') : AppLocalizations.of(context).translate('home.device_offline'),
style: TextStyle(fontSize: 14, color: device.isOnline ? const Color(0xFF00C853) : const Color(0xFF999999)),
),
),
],
),
child: Text(
device.isOnline ? AppLocalizations.of(context).translate('home.device_online') : AppLocalizations.of(context).translate('home.device_offline'),
style: TextStyle(fontSize: 14, color: device.isOnline ? const Color(0xFF00C853) : const Color(0xFF999999)),
),
// 🔥 视角切换按钮
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: _viewConfigs.asMap().entries.map((entry) {
final index = entry.key;
final config = entry.value;
final isSelected = _currentViewIndex == index;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: ElevatedButton(
onPressed: () {
setState(() {
_currentViewIndex = index;
});
debugPrint('🎬 [MachineDetails] 切换视角: ${config['name']}');
},
style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? const Color(0xFF165DFF) : Colors.grey[200],
foregroundColor: isSelected ? Colors.white : Colors.grey[700],
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(config['icon'] as IconData, size: 16),
const SizedBox(height: 2),
Text(
config['name'] as String,
style: const TextStyle(fontSize: 10),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
}).toList(),
),
),
],

View File

@@ -163,14 +163,41 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
@override
Widget build(BuildContext context) {
if (!_isInitialized)
return const Scaffold(
backgroundColor: Colors.black,
body: Center(child: CircularProgressIndicator()),
// 🔥 如果 URL 为空且未初始化,返回黑色占位符
if (!_isInitialized && widget.streamUrl.isEmpty) {
return Container(
color: Colors.black, // 🔥 使用黑色背景
child: const Center(
child: Text(
'无视频信号',
style: TextStyle(color: Colors.white54, fontSize: 14),
),
),
);
}
// 🔥 如果正在初始化但有 URL,显示加载指示器(使用黑色背景)
if (!_isInitialized) {
return Container(
color: Colors.black, // 🔥 使用黑色背景
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(color: Colors.white),
const SizedBox(height: 12),
Text(
'视频加载中...',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
],
),
),
);
}
return Scaffold(
backgroundColor: Colors.black,
backgroundColor: Colors.black, // 🔥 使用黑色背景
body: LayoutBuilder(
builder: (context, constraints) {
final double pipW = constraints.maxWidth / 5;
@@ -191,20 +218,8 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
return Stack(
children: [
// ① 背景虚化层(背景两侧模糊,模拟视觉聚焦)
Positioned.fill(
child: Stack(
children: [
_buildQuadrantView(alignment: widget.isFrontMain ? Alignment.topLeft : Alignment.topRight),
Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(color: Colors.black.withOpacity(0.55)),
),
),
],
),
),
// 🔥 已移除背景虚化层,避免遮挡上方内容
// 主视频层的羽化效果由 _buildMainViewWithFeathering() 处理
// ② 主视频层(🔥 去掉双击,交给外部控制)
Center(

View File

@@ -2,6 +2,7 @@
class RobotDataModel {
final String name;
final String id;
final String? alias; // 设备别名
final String type;
final String status;
final double battery;
@@ -10,6 +11,7 @@ class RobotDataModel {
const RobotDataModel({
required this.name,
required this.id,
this.alias,
required this.type,
required this.status,
required this.battery,
@@ -18,13 +20,16 @@ class RobotDataModel {
/// 从 JSON 创建数据模型
factory RobotDataModel.fromJson(Map<String, dynamic> json) {
final batteryValue = (json['battery'] as num?)?.toDouble() ??
(json['capacity_percent'] as num?)?.toDouble() ?? 100.0;
return RobotDataModel(
name: json['deviceName'] ?? json['name'] ?? '',
id: json['deviceId']?.toString() ?? json['id']?.toString() ?? '',
alias: json['deviceAlias'] as String?, // 获取别名字段
type: json['deviceTypeName'] ?? json['type'] ?? '未知类型',
status: _getStatusText(json['status'] ?? 0),
battery: (json['battery'] as num?)?.toDouble() ??
(json['capacity_percent'] as num?)?.toDouble() ?? 0.0,
battery: batteryValue,
task: json['task'] ?? json['currentTask'] ?? '待机中',
);
}
@@ -34,6 +39,7 @@ class RobotDataModel {
return {
'name': name,
'id': id,
'alias': alias,
'type': type,
'status': status,
'battery': battery,

View File

@@ -0,0 +1,107 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import '../model/robot_status_model.dart';
import '../service/robot_status_service.dart';
import '../manager/float_bar_manager.dart';
/// 悬浮条事件
abstract class FloatBarEvent {}
/// 切换折叠/展开状态事件
class ToggleExpandEvent extends FloatBarEvent {}
/// 更新状态数据事件
class UpdateStatusEvent extends FloatBarEvent {
final RobotStatusModel status;
UpdateStatusEvent(this.status);
}
/// 悬浮条状态
abstract class FloatBarState {}
/// 折叠状态
class FloatBarCollapsedState extends FloatBarState {
final RobotStatusModel status;
FloatBarCollapsedState(this.status);
}
/// 展开状态
class FloatBarExpandedState extends FloatBarState {
final RobotStatusModel status;
FloatBarExpandedState(this.status);
}
/// 悬浮条Bloc
class FloatBarBloc extends Bloc<FloatBarEvent, FloatBarState> {
final RobotStatusService _statusService;
final FloatBarManager? _floatBarManager;
StreamSubscription? _statusSubscription;
FloatBarBloc(this._statusService, [this._floatBarManager])
: super(FloatBarCollapsedState(_statusService.currentStatus)) {
// 监听服务层数据流
_startListening();
on<ToggleExpandEvent>(_handleToggleExpand);
on<UpdateStatusEvent>(_handleUpdateStatus);
}
/// 开始监听服务层数据
void _startListening() {
_statusSubscription?.cancel();
_statusSubscription = _statusService.statusStream.listen((status) {
add(UpdateStatusEvent(status));
});
}
/// 处理切换折叠/展开
void _handleToggleExpand(
ToggleExpandEvent event,
Emitter<FloatBarState> emit,
) {
final currentState = state;
if (currentState is FloatBarCollapsedState) {
emit(FloatBarExpandedState(currentState.status));
} else if (currentState is FloatBarExpandedState) {
emit(FloatBarCollapsedState(currentState.status));
}
// 触发UI刷新(使用 ?. 处理空安全)
_floatBarManager?.refresh();
}
/// 处理状态数据更新
void _handleUpdateStatus(
UpdateStatusEvent event,
Emitter<FloatBarState> emit,
) {
final currentState = state;
if (currentState is FloatBarCollapsedState) {
emit(FloatBarCollapsedState(event.status));
} else if (currentState is FloatBarExpandedState) {
emit(FloatBarExpandedState(event.status));
}
// 触发UI刷新(使用 ?. 处理空安全)
_floatBarManager?.refresh();
}
/// 启动状态服务
void startService() {
_statusService.start();
}
/// 停止状态服务
void stopService() {
_statusService.stop();
}
@override
Future<void> close() {
_statusSubscription?.cancel();
_statusService.stop();
return super.close();
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 悬浮条设置服务 - 简化版
/// 使用静态变量存储状态,确保全局同步
class FloatBarSettingService {
final SharedPreferences _prefs;
static const String _key = 'float_bar_enabled';
/// 🔥 静态实例引用
static FloatBarSettingService? _instance;
/// 🔥 静态状态变量 - 所有组件共享
static bool _isEnabled = true;
/// 🔥 静态 ValueNotifier - 用于通知UI变化
static final ValueNotifier<bool> _settingNotifier = ValueNotifier<bool>(true);
FloatBarSettingService(this._prefs) {
_instance = this;
// 从持久化读取初始状态
_isEnabled = _prefs.getBool(_key) ?? true;
_settingNotifier.value = _isEnabled;
print('✅ [FloatBarSettingService] 初始化完成,初始状态: $_isEnabled');
}
/// 获取静态实例
static FloatBarSettingService? get instance => _instance;
/// 获取 ValueNotifier
static ValueNotifier<bool> get settingNotifier => _settingNotifier;
/// 获取当前是否启用(直接从静态变量读取)
static bool get isEnabled => _isEnabled;
/// 设置是否启用
static Future<void> setEnabled(bool enabled) async {
print('🔍 [FloatBarSettingService] setEnabled 被调用,新值: $enabled');
// 1. 更新静态变量
_isEnabled = enabled;
print('🔍 [FloatBarSettingService] 静态变量已更新: $_isEnabled');
// 2. 更新 ValueNotifier(通知所有监听者)
_settingNotifier.value = enabled;
print(
'🔍 [FloatBarSettingService] ValueNotifier 已更新: ${_settingNotifier.value}',
);
// 3. 持久化到 SharedPreferences
final instance = _instance;
if (instance != null) {
await instance._prefs.setBool(_key, enabled);
print('🔍 [FloatBarSettingService] 已保存到 SharedPreferences');
}
}
/// 切换开关
static Future<void> toggle() async {
await setEnabled(!_isEnabled);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/foundation.dart';
/// 悬浮条控制器 - 极简版
/// 使用静态变量管理全局状态
class FloatBarController {
/// 🔥 是否显示悬浮条
static bool isVisible = true;
/// 🔥 状态变化通知器
static final ValueNotifier<bool> visibilityNotifier = ValueNotifier<bool>(true);
/// 设置显示/隐藏
static void setVisible(bool visible) {
if (isVisible != visible) {
isVisible = visible;
visibilityNotifier.value = visible;
}
}
/// 切换显示状态
static void toggle() {
setVisible(!isVisible);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import '../view/float_bar_widget.dart';
/// 全局悬浮条管理器
/// 单例模式,负责管理OverlayEntry的创建、显示、隐藏和刷新
class FloatBarManager {
static final FloatBarManager _instance = FloatBarManager._internal();
factory FloatBarManager() => _instance;
FloatBarManager._internal();
/// OverlayEntry实例
OverlayEntry? _overlayEntry;
/// 是否已初始化
bool _isInitialized = false;
/// 全局上下文
BuildContext? _globalContext;
/// 初始化管理器,保存全局上下文
void initialize(BuildContext context) {
if (_isInitialized) return;
_globalContext = context;
_isInitialized = true;
}
/// 显示悬浮条
void show() {
if (!_isInitialized || _globalContext == null) {
throw Exception('FloatBarManager has not been initialized!');
}
if (_overlayEntry != null) {
// 已有浮层,先移除再重新创建
hide();
}
_overlayEntry = OverlayEntry(
builder: (context) => const FloatBarWidget(),
);
Overlay.of(_globalContext!)?.insert(_overlayEntry!);
}
/// 隐藏悬浮条
void hide() {
if (_overlayEntry != null) {
_overlayEntry!.remove();
_overlayEntry = null;
}
}
/// 强制刷新UI
void refresh() {
_overlayEntry?.markNeedsBuild();
}
/// 检查浮层是否显示中
bool get isVisible => _overlayEntry != null;
/// 释放资源
void dispose() {
hide();
_globalContext = null;
_isInitialized = false;
}
}

View File

@@ -0,0 +1,104 @@
import 'package:equatable/equatable.dart';
/// 机器人状态数据模型
class RobotStatusModel extends Equatable {
/// 任务名称
final String taskName;
/// 电量百分比
final int battery;
/// 设备状态:idle/running/charging/error
final String status;
/// 信号强度
final int signal;
/// 当前位置
final String location;
/// 速度
final double speed;
/// 温度
final int temperature;
/// 运行时间
final String runTime;
const RobotStatusModel({
this.taskName = '未知任务',
this.battery = 100,
this.status = 'idle',
this.signal = 100,
this.location = '未知位置',
this.speed = 0.0,
this.temperature = 25,
this.runTime = '00:00:00',
});
/// 创建副本
RobotStatusModel copyWith({
String? taskName,
int? battery,
String? status,
int? signal,
String? location,
double? speed,
int? temperature,
String? runTime,
}) {
return RobotStatusModel(
taskName: taskName ?? this.taskName,
battery: battery ?? this.battery,
status: status ?? this.status,
signal: signal ?? this.signal,
location: location ?? this.location,
speed: speed ?? this.speed,
temperature: temperature ?? this.temperature,
runTime: runTime ?? this.runTime,
);
}
/// 状态描述文本
String get statusText {
switch (status) {
case 'running':
return '运行中';
case 'charging':
return '充电中';
case 'error':
return '故障';
case 'idle':
default:
return '待机';
}
}
/// 状态颜色
String get statusColor {
switch (status) {
case 'running':
return '#00C853';
case 'charging':
return '#03DAC6';
case 'error':
return '#FF5252';
case 'idle':
default:
return '#9E9E9E';
}
}
@override
List<Object?> get props => [
taskName,
battery,
status,
signal,
location,
speed,
temperature,
runTime,
];
}

View File

@@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:math';
import '../model/robot_status_model.dart';
/// 机器人状态服务
/// 负责模拟设备状态推送,实际项目中应替换为真实的TCP/接口对接
class RobotStatusService {
static final RobotStatusService _instance = RobotStatusService._internal();
factory RobotStatusService() => _instance;
RobotStatusService._internal();
/// 状态数据流控制器
final StreamController<RobotStatusModel> _statusController =
StreamController.broadcast();
/// 当前状态
RobotStatusModel _currentStatus = const RobotStatusModel();
/// 模拟定时器
Timer? _timer;
/// 状态数据流
Stream<RobotStatusModel> get statusStream => _statusController.stream;
/// 获取当前状态
RobotStatusModel get currentStatus => _currentStatus;
/// 启动状态推送
void start() {
if (_timer != null) return;
// 立即发送初始状态
_statusController.add(_currentStatus);
// 模拟每3秒更新一次状态
_timer = Timer.periodic(const Duration(seconds: 3), (timer) {
_simulateStatusUpdate();
});
}
/// 停止状态推送
void stop() {
_timer?.cancel();
_timer = null;
}
/// 手动更新状态(用于外部触发更新)
void updateStatus(RobotStatusModel status) {
_currentStatus = status;
_statusController.add(status);
}
/// 模拟状态更新
void _simulateStatusUpdate() {
final random = Random();
final statuses = ['idle', 'running', 'charging', 'error'];
_currentStatus = _currentStatus.copyWith(
battery: max(0, _currentStatus.battery + random.nextInt(3) - 1),
status: random.nextDouble() > 0.95 ? statuses[random.nextInt(statuses.length)] : _currentStatus.status,
signal: min(100, max(0, _currentStatus.signal + random.nextInt(5) - 2)),
speed: _currentStatus.status == 'running' ? random.nextDouble() * 5 : 0,
temperature: min(50, max(20, _currentStatus.temperature + random.nextInt(3) - 1)),
runTime: _updateRunTime(),
);
_statusController.add(_currentStatus);
}
/// 更新运行时间
String _updateRunTime() {
if (_currentStatus.status != 'running') {
return _currentStatus.runTime;
}
final parts = _currentStatus.runTime.split(':');
int hours = int.parse(parts[0]);
int minutes = int.parse(parts[1]);
int seconds = int.parse(parts[2]);
seconds++;
if (seconds >= 60) {
seconds = 0;
minutes++;
}
if (minutes >= 60) {
minutes = 0;
hours++;
}
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
/// 释放资源
void dispose() {
stop();
_statusController.close();
}
}

View File

@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'float_bar_controller.dart';
/// 简单悬浮条组件
class SimpleFloatBar extends StatefulWidget {
const SimpleFloatBar({super.key});
@override
State<SimpleFloatBar> createState() => _SimpleFloatBarState();
}
class _SimpleFloatBarState extends State<SimpleFloatBar> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
child: GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
height: _isExpanded ? 200 : 56,
width: double.infinity,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFE8F5E9), Color(0xFFFFFFFF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.green.withOpacity(0.2), width: 1),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 12,
offset: const Offset(0, 4),
spreadRadius: 2,
),
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 20,
offset: const Offset(0, 8),
spreadRadius: 1,
),
],
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: _isExpanded
? SingleChildScrollView(child: _buildExpanded())
: _buildCollapsed(),
),
),
),
);
}
Widget _buildCollapsed() {
return Row(children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.green, borderRadius: BorderRadius.circular(5))),
const SizedBox(width: 12),
const Expanded(child: Text('设备运行中', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.black87))),
const SizedBox(width: 12),
Row(children: const [Icon(Icons.battery_full, size: 18, color: Colors.grey), SizedBox(width: 4), Text('85%', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500))]),
const SizedBox(width: 8),
const Icon(Icons.keyboard_arrow_up, size: 20, color: Colors.grey),
const SizedBox(width: 8),
_buildCloseButton(),
]);
}
Widget _buildExpanded() {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.green, borderRadius: BorderRadius.circular(5))),
const SizedBox(width: 8),
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration(color: Colors.green.withOpacity(0.1), borderRadius: BorderRadius.circular(4)), child: const Text('运行中', style: TextStyle(fontSize: 12, color: Colors.green, fontWeight: FontWeight.w500))),
const SizedBox(width: 12),
const Expanded(child: Text('设备运行中', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Colors.black87))),
const Icon(Icons.keyboard_arrow_down, size: 20, color: Colors.grey),
const SizedBox(width: 8),
_buildCloseButton(),
]),
const SizedBox(height: 16),
Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [
_infoItem(Icons.battery_full, '电量', '85%'),
_infoItem(Icons.signal_cellular_alt, '信号', '100%'),
_infoItem(Icons.speed, '速度', '0.0m/s'),
_infoItem(Icons.thermostat, '温度', '25°C'),
]),
const SizedBox(height: 12),
Row(children: const [
Icon(Icons.location_on, size: 14, color: Colors.grey),
SizedBox(width: 4),
Expanded(child: Text('北京市朝阳区', style: TextStyle(fontSize: 12, color: Colors.grey))),
SizedBox(width: 12),
Icon(Icons.timer, size: 14, color: Colors.grey),
SizedBox(width: 4),
Text('02:35:18', style: TextStyle(fontSize: 12, color: Colors.grey)),
]),
]);
}
Widget _buildCloseButton() {
return GestureDetector(
onTap: () => FloatBarController.setVisible(false),
child: Container(padding: const EdgeInsets.all(4), decoration: BoxDecoration(color: Colors.grey.withOpacity(0.1), borderRadius: BorderRadius.circular(8)), child: const Icon(Icons.close, size: 16, color: Colors.grey)),
);
}
Widget _infoItem(IconData icon, String label, String value) {
return Column(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 20, color: Colors.grey),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 10, color: Colors.grey)),
const SizedBox(height: 2),
Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
]);
}
}

View File

@@ -0,0 +1,350 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/float_bar_bloc.dart';
import '../model/robot_status_model.dart';
import '../service/robot_status_service.dart';
import '../cubit/float_bar_setting_cubit.dart';
/// 悬浮条UI组件
/// 内部独立管理Bloc,不依赖外部注入
class FloatBarWidget extends StatefulWidget {
const FloatBarWidget({super.key});
@override
State<FloatBarWidget> createState() => _FloatBarWidgetState();
}
class _FloatBarWidgetState extends State<FloatBarWidget> {
late final FloatBarBloc _bloc;
@override
void initState() {
super.initState();
debugPrint('🔥 [FloatBarWidget] initState - 开始创建 Bloc');
// 内部创建Bloc并启动服务(不需要FloatBarManager,因为我们使用Stack方式)
_bloc = FloatBarBloc(RobotStatusService());
_bloc.startService();
debugPrint('✅ [FloatBarWidget] Bloc 已创建并启动服务');
}
@override
void dispose() {
debugPrint('🔥 [FloatBarWidget] dispose - 关闭 Bloc');
_bloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
debugPrint('🔥 [FloatBarWidget] build - 渲染悬浮条');
return BlocProvider.value(value: _bloc, child: const _FloatBarContent());
}
}
/// 悬浮条内容组件
class _FloatBarContent extends StatelessWidget {
const _FloatBarContent();
@override
Widget build(BuildContext context) {
return BlocBuilder<FloatBarBloc, FloatBarState>(
builder: (context, state) {
final isExpanded = state is FloatBarExpandedState;
final status = state is FloatBarCollapsedState
? state.status
: (state as FloatBarExpandedState).status;
debugPrint(
'🔥 [FloatBarContent] build - isExpanded: $isExpanded, status: ${status.taskName}',
);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), // 底部留出 Tab 栏空间
child: GestureDetector(
onTap: () {
debugPrint('🔥 [FloatBarContent] 点击悬浮条');
context.read<FloatBarBloc>().add(ToggleExpandEvent());
},
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
height: isExpanded ? 180 : 56,
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFFE8F5E9), // 浅绿色
Color(0xFFFFFFFF), // 白色
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.green.withOpacity(0.2),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 12,
offset: const Offset(0, 4),
spreadRadius: 2,
),
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 20,
offset: const Offset(0, 8),
spreadRadius: 1,
),
BoxShadow(
color: Colors.white.withOpacity(0.6),
blurRadius: 8,
offset: const Offset(0, -2),
spreadRadius: -2,
),
],
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: isExpanded
? _buildExpandedContent(status)
: _buildCollapsedContent(status),
),
),
),
);
},
);
}
/// 折叠态内容
Widget _buildCollapsedContent(RobotStatusModel status) {
return Row(
children: [
// 状态指示灯
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _parseColor(status.statusColor),
borderRadius: BorderRadius.circular(5),
),
),
const SizedBox(width: 12),
// 任务名称
Expanded(
child: Text(
status.taskName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
// 电量
Row(
children: [
Icon(
status.battery > 20 ? Icons.battery_full : Icons.battery_alert,
size: 18,
color: status.battery > 20 ? Colors.grey : Colors.red,
),
const SizedBox(width: 4),
Text(
'${status.battery}%',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: status.battery > 20 ? Colors.black87 : Colors.red,
),
),
],
),
const SizedBox(width: 8),
// 展开箭头
const Icon(Icons.keyboard_arrow_up, size: 20, color: Colors.grey),
const SizedBox(width: 8),
// 关闭按钮
GestureDetector(
onTap: () {
debugPrint('🔥 [FloatBarContent] 点击关闭按钮');
// 🔥 使用静态方法关闭悬浮条
FloatBarSettingService.setEnabled(false);
},
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.close, size: 16, color: Colors.grey),
),
),
],
);
}
/// 展开态内容
Widget _buildExpandedContent(RobotStatusModel status) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部:状态+任务名+折叠按钮
Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _parseColor(status.statusColor),
borderRadius: BorderRadius.circular(5),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _parseColor(status.statusColor).withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
status.statusText,
style: TextStyle(
fontSize: 12,
color: _parseColor(status.statusColor),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
status.taskName,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.keyboard_arrow_down, size: 20, color: Colors.grey),
const SizedBox(width: 8),
// 关闭按钮
GestureDetector(
onTap: () {
debugPrint('🔥 [FloatBarContent] 点击关闭按钮(展开态)');
// 🔥 使用静态方法关闭悬浮条
FloatBarSettingService.setEnabled(false);
},
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.close, size: 16, color: Colors.grey),
),
),
],
),
const SizedBox(height: 16),
// 中间:详细信息网格
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildInfoItem(
status.battery > 20 ? Icons.battery_full : Icons.battery_alert,
'电量',
'${status.battery}%',
status.battery > 20 ? Colors.black87 : Colors.red,
),
_buildInfoItem(
Icons.signal_cellular_alt,
'信号',
'${status.signal}%',
Colors.black87,
),
_buildInfoItem(
Icons.speed,
'速度',
'${status.speed.toStringAsFixed(1)}m/s',
Colors.black87,
),
_buildInfoItem(
Icons.thermostat,
'温度',
'${status.temperature}°C',
Colors.black87,
),
],
),
const SizedBox(height: 12),
// 底部:位置+时间
Row(
children: [
const Icon(Icons.location_on, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Expanded(
child: Text(
status.location,
style: const TextStyle(fontSize: 12, color: Colors.grey),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
const Icon(Icons.timer, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Text(
status.runTime,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
],
);
}
/// 信息项组件
Widget _buildInfoItem(
IconData icon,
String label,
String value,
Color valueColor,
) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20, color: Colors.grey),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 10, color: Colors.grey)),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: valueColor,
),
),
],
);
}
/// 解析颜色字符串
Color _parseColor(String colorStr) {
try {
return Color(int.parse(colorStr.replaceFirst('#', '0xFF')));
} catch (_) {
return Colors.grey;
}
}
}

View File

@@ -13,9 +13,15 @@ import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_statu
import '../bloc/drone_station_bloc.dart';
import '../bloc/drone_station_event.dart';
import '../bloc/drone_station_state.dart';
import '../bloc/robot_list_bloc.dart'; // 🔥 添加 RobotListBloc 导入
import '../bloc/robot_list_event.dart'; // 🔥 添加 RobotListEvent 导入
import '../bloc/robot_list_state.dart'; // 🔥 添加 RobotListState 导入
import '../widgets/device_item_widget.dart';
import '../widgets/drone_station_item_card.dart';
import '../widgets/robot_item_card.dart'; // 🔥 添加 RobotItemCard 导入
import '../../domain/entities/drone_station_entity.dart'; // 🔥 添加 DroneStationEntity 导入
import 'robot_list_page.dart';
import 'robot_control_page.dart';
import 'drone_station_detail_page.dart';
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
@@ -51,35 +57,36 @@ class DeviceStatusView extends StatelessWidget {
child: Scaffold(
backgroundColor: const Color(0xFFF7F7F7),
body: SafeArea(
child: BlocConsumer<
DeviceListBloc.DeviceStatusBloc,
DeviceListState.DeviceStatusState
>(
listener: (context, state) {
// 🔥 监听错误状态,显示友好提示
if (state is DeviceListState.DeviceStatusError &&
state.shouldShowError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.orange,
),
);
}
},
builder: (context, state) {
return Column(
children: [
_buildAppBar(context),
_buildSearchBar(context),
_buildTypeFilterBar(context),
Expanded(child: _buildContent(context, state)),
],
);
},
),
child:
BlocConsumer<
DeviceListBloc.DeviceStatusBloc,
DeviceListState.DeviceStatusState
>(
listener: (context, state) {
// 🔥 监听错误状态,显示友好提示
if (state is DeviceListState.DeviceStatusError &&
state.shouldShowError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.orange,
),
);
}
},
builder: (context, state) {
return Column(
children: [
_buildAppBar(context),
_buildSearchBar(context),
_buildTypeFilterBar(context),
Expanded(child: _buildContent(context, state)),
],
);
},
),
),
),
);
@@ -317,25 +324,11 @@ class DeviceStatusView extends StatelessWidget {
children: [
_buildStatusCard(state),
const SizedBox(height: 20.0),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: filteredDevices.length,
itemBuilder: (context, index) {
final device = filteredDevices[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DeviceItemWidget(
device: device,
onTap: () {
debugPrint('点击设备: ${device.name}');
// TODO: 导航到设备详情页
},
),
);
},
),
// 🔥 机器人列表(直接复用 RobotListPage 的布局)
if (state.selectedType == '全部') _buildEmbeddedRobotList(context),
// 🔥 无人机机场列表(直接复用 _buildDroneStationList 的布局)
if (state.selectedType == '全部')
_buildDroneStationList(context, embedded: true),
],
),
);
@@ -344,7 +337,87 @@ class DeviceStatusView extends StatelessWidget {
return const Center(child: Text('暂无数据'));
}
Widget _buildDroneStationList(BuildContext context) {
/// 🔥 嵌入式机器人列表(直接复用 RobotListPage 的布局)
Widget _buildEmbeddedRobotList(BuildContext context) {
final selectedSite = sl<SiteCubit>().state.selectedSite;
final siteId = selectedSite?.id;
debugPrint('🔍 [EmbeddedRobotList] 开始加载机器人数据, siteId: $siteId');
return BlocProvider(
create: (_) =>
sl<RobotListBloc>()..add(RobotListLoadData(siteId: siteId)),
child: BlocConsumer<RobotListBloc, RobotListState>(
listener: (context, state) {
if (state is RobotListError && state.shouldShowError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.orange,
),
);
}
},
builder: (context, state) {
debugPrint('🔍 [EmbeddedRobotList] 当前状态: ${state.runtimeType}');
if (state is RobotListLoading) {
return const Padding(
padding: EdgeInsets.all(32),
child: Center(
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
),
);
}
if (state is RobotListLoaded) {
debugPrint(
'✅ [EmbeddedRobotList] 加载成功,机器人数量: ${state.robots.length}',
);
// 🔥 直接复用 RobotListPage 的布局结构(不显示标题)
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...state.robots.map((robot) {
debugPrint(
'📍 [EmbeddedRobotList] 机器人 - name: ${robot.name}, id: ${robot.id}',
);
return RobotItemCard(
name: robot.name,
id: robot.id,
type: robot.type,
status: robot.status,
battery: robot.battery,
task: robot.task,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
RobotControlPage(robot: robot.toJson()),
),
);
},
);
}),
],
);
}
if (state is RobotListError) {
debugPrint('❌ [EmbeddedRobotList] 加载失败: ${state.message}');
}
return const SizedBox.shrink();
},
),
);
}
Widget _buildDroneStationList(BuildContext context, {bool embedded = false}) {
// 从全局 SiteCubit 获取选中的场站 ID
final selectedSite = sl<SiteCubit>().state.selectedSite;
@@ -414,6 +487,10 @@ class DeviceStatusView extends StatelessWidget {
},
color: const Color(0xFF165DFF),
child: ListView.builder(
shrinkWrap: embedded,
physics: embedded
? const NeverScrollableScrollPhysics()
: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(top: 16, bottom: 16),
itemCount: stations.length,
itemBuilder: (context, index) {
@@ -557,4 +634,153 @@ class DeviceStatusView extends StatelessWidget {
},
);
}
/// 🔥 根据设备类型导航到不同的详情页
void _navigateToDeviceDetail(BuildContext context, dynamic device) {
final deviceName = device.name ?? '';
final deviceId = device.deviceId ?? '';
final deviceType = device.type ?? ''; // 🔥 使用接口返回的设备类型字段
debugPrint('🔍 [DeviceStatusPage] 设备名称: $deviceName');
debugPrint('🔍 [DeviceStatusPage] 设备ID: $deviceId');
debugPrint('🔍 [DeviceStatusPage] 设备类型: $deviceType');
// 🔥 判断是否为机器人(根据设备类型前缀)
final robotPrefixes = [
'RCHETD-CN',
'MC700PLUS-CN',
'MC700-CN',
'MC700PRO-CN',
'RCETD-CN',
'MC700AIR-CN',
];
bool isRobot = robotPrefixes.any((prefix) => deviceType.startsWith(prefix));
// 🔥 判断是否为无人机机场(根据设备类型包含关键字)
final droneStationKeywords = [
'DJI Dock3',
'DOCK3',
'DOCK-2',
'采集车',
'机场',
'无人机机场',
];
bool isDroneStation = droneStationKeywords.any(
(keyword) => deviceType.contains(keyword),
);
// 🔥 兜底逻辑:如果 type 字段是"未知设备"或空,则使用设备名称进行二次判断
if (!isRobot &&
!isDroneStation &&
(deviceType.isEmpty || deviceType == '未知设备' || deviceType == '未知类型')) {
debugPrint('⚠️ [DeviceStatusPage] type 字段无效,使用设备名称进行二次判断');
// 检查每个机器人前缀是否匹配
for (final prefix in robotPrefixes) {
final nameMatch = deviceName.startsWith(prefix);
final idMatch = deviceId.startsWith(prefix);
if (nameMatch || idMatch) {
debugPrint(
'🔍 [DeviceStatusPage] 机器人前缀匹配: "$prefix" -> 名称匹配=$nameMatch, ID匹配=$idMatch',
);
}
}
// 根据设备名称前缀判断机器人
isRobot = robotPrefixes.any(
(prefix) =>
deviceName.startsWith(prefix) || deviceId.startsWith(prefix),
);
// 检查每个无人机关键字是否匹配
for (final keyword in droneStationKeywords) {
if (deviceName.contains(keyword)) {
debugPrint('🔍 [DeviceStatusPage] 无人机关键字匹配: "$keyword"');
}
}
// 根据设备名称包含关键字判断无人机机场
isDroneStation = droneStationKeywords.any(
(keyword) => deviceName.contains(keyword),
);
// 🔥 重要:如果同时匹配机器人和无人机机场,优先判断为无人机机场
if (isRobot && isDroneStation) {
debugPrint('⚠️ [DeviceStatusPage] 同时匹配机器人和无人机机场,优先判断为无人机机场');
isRobot = false;
isDroneStation = true;
}
debugPrint('🔍 [DeviceStatusPage] 二次判断 - 是否机器人: $isRobot');
debugPrint('🔍 [DeviceStatusPage] 二次判断 - 是否无人机机场: $isDroneStation');
}
debugPrint('🔍 [DeviceStatusPage] 最终判断 - 是否机器人: $isRobot');
debugPrint('🔍 [DeviceStatusPage] 最终判断 - 是否无人机机场: $isDroneStation');
if (isRobot) {
// 跳转到机器人控制页面
debugPrint('🚀 [DeviceStatusPage] 跳转到机器人控制页面');
final robotMap = {
'name': deviceName,
'id': deviceId,
'type': deviceType,
'status': device.status ?? '在线',
'battery': 100.0, // DeviceEntity 没有 battery 字段,使用默认值
'task': '待机中', // DeviceEntity 没有 task 字段,使用默认值
};
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RobotControlPage(robot: robotMap),
),
);
} else if (isDroneStation) {
// 跳转到无人机机场详情页面
debugPrint('🚀 [DeviceStatusPage] 跳转到无人机机场详情页面');
// 🔥 从 DroneStationBloc 中获取已加载的机场列表
final droneStationBloc = sl<DroneStationBloc>();
DroneStationEntity? matchedStation;
if (droneStationBloc.state is DroneStationLoaded) {
final loadedState = droneStationBloc.state as DroneStationLoaded;
// 根据 deviceId 或 deviceName 匹配
for (final station in loadedState.stations) {
if (station.gatewaySn == deviceId || station.callsign == deviceName) {
matchedStation = station;
debugPrint(
'✅ 匹配到机场 - gatewaySn: ${station.gatewaySn}, deviceSn: ${station.deviceSn}',
);
break;
}
}
}
if (matchedStation != null) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
DroneStationDetailPage(station: matchedStation!),
),
);
} else {
debugPrint('⚠️ 未找到匹配的机场,请从无人机机场标签页进入');
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('请从"无人机机场"标签页查看详情')));
}
} else {
// 其他设备类型,显示提示
debugPrint('⚠️ [DeviceStatusPage] 未知设备类型: $deviceType');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('该设备类型暂不支持查看详情: $deviceType'),
duration: const Duration(seconds: 2),
),
);
}
}
}

View File

@@ -6,6 +6,8 @@ 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';
import '../widgets/drone_station_osd_card.dart'; // 🔥 添加机场 OSD 卡片
import '../widgets/drone_osd_card.dart'; // 🔥 添加无人机 OSD 卡片
import 'drone_video_control_page.dart';
import 'drone_mission_control_page.dart';
import 'drone_monitor_page.dart';
@@ -27,8 +29,6 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
UAVDetailEntity? _detail;
String? _droneSn;
// 无人机状态轮询计时器
Timer? _droneStatusPollingTimer;
@@ -44,59 +44,59 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
);
// 启动无人机状态轮询(每5秒刷新一次)
_startDroneStatusPolling();
// 🔥 已禁用自动轮询,改为手动下拉刷新
// _startDroneStatusPolling();
}
@override
void dispose() {
_bloc.close();
_droneStatusPollingTimer?.cancel(); // 停止轮询
// 🔥 已禁用自动轮询,无需停止
// _droneStatusPollingTimer?.cancel();
super.dispose();
}
/// 启动无人机状态轮询
/// 🔥 已禁用自动轮询功能
void _startDroneStatusPolling() {
_scheduleDroneStatusPoll();
// _scheduleDroneStatusPoll();
}
/// 根据无人机状态动态调整轮询周期
/// 🔥 已禁用自动轮询功能
void _scheduleDroneStatusPoll() {
if (!mounted) return;
// 检查当前无人机状态
Duration interval = const Duration(seconds: 60); // 默认60秒
final currentState = _bloc.state;
if (currentState is UAVDetailLoaded) {
if (currentState.detail.droneOnlineStatus == 1) {
// 无人机在线时,每30秒轮询一次
interval = const Duration(seconds: 30);
} else {
// 无人机离线时,每60秒轮询一次(降低频率)
interval = const Duration(seconds: 60);
}
}
_droneStatusPollingTimer?.cancel();
_droneStatusPollingTimer = Timer(interval, () {
if (!mounted) return;
debugPrint('🔄 定时刷新无人机状态...');
_bloc.add(
UAVDetailLoad(
gatewaySn: widget.station.gatewaySn,
deviceSn: widget.station.deviceSn,
),
);
// 重新调度下一次轮询(动态周期)
_scheduleDroneStatusPoll();
});
// if (!mounted) return;
//
// // 检查当前无人机状态
// Duration interval = const Duration(seconds: 60); // 默认60秒
// final currentState = _bloc.state;
// if (currentState is UAVDetailLoaded) {
// if (currentState.detail.droneOnlineStatus == 1) {
// // 无人机在线时,每30秒轮询一次
// interval = const Duration(seconds: 30);
// } else {
// // 无人机离线时,每60秒轮询一次(降低频率)
// interval = const Duration(seconds: 60);
// }
// }
//
// _droneStatusPollingTimer?.cancel();
// _droneStatusPollingTimer = Timer(interval, () {
// if (!mounted) return;
//
// debugPrint('🔄 定时刷新无人机状态...');
// _bloc.add(
// UAVDetailLoad(
// gatewaySn: widget.station.gatewaySn,
// deviceSn: widget.station.deviceSn,
// ),
// );
//
// // 重新调度下一次轮询(动态周期)
// _scheduleDroneStatusPoll();
// });
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
@@ -182,22 +182,35 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
_detail = detail; // 保存详情数据供其他方法使用
_droneSn = detail.deviceSn; // 保存无人机序列号
return RefreshIndicator(
onRefresh: _handleRefresh,
color: const Color(0xFF165DFF),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildAirportStatusCard(detail),
const SizedBox(height: 12),
_buildMonitorCard(),
const SizedBox(height: 12),
_buildDroneStatusCard(detail),
const SizedBox(height: 12),
_buildQuickActions(),
const SizedBox(height: 20),
],
onRefresh: _handleRefresh,
color: const Color(0xFF165DFF),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildAirportStatusCard(detail),
const SizedBox(height: 12),
// 🔥 添加机场 OSD 实时数据卡片
DroneStationOsdCard(
gatewaySn: widget.station.gatewaySn,
isOnline: detail.isOnline,
),
);
const SizedBox(height: 12),
// 🔥 添加无人机 OSD 实时数据卡片(无人机在线时显示)
DroneOsdCard(
deviceSn: widget.station.deviceSn,
gatewaySn: widget.station.gatewaySn,
isDroneOnline: detail.isDroneOnline,
),
const SizedBox(height: 12),
_buildMonitorCard(),
const SizedBox(height: 12),
_buildDroneStatusCard(detail),
const SizedBox(height: 12),
_buildQuickActions(),
const SizedBox(height: 20),
],
),
);
}
/// 处理下拉刷新
@@ -210,7 +223,8 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
),
);
// 重置轮询计时器,使用新的状态
_scheduleDroneStatusPoll();
// 🔥 已禁用自动轮询,无需重置
// _scheduleDroneStatusPoll();
}
Widget _buildMonitorCard() {
@@ -348,7 +362,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
'风速',
detail.windSpeed != null ? '${detail.windSpeed} m/s' : '未知',
),
_buildInfoRow('降雨量', detail.rainfall ?? '未知'),
_buildInfoRow('降雨量', _formatRainfall(detail.rainfall)),
_buildInfoRow('网络状态', detail.networkState?.toString() ?? '未知'),
_buildPositionStateRow('位置状态', detail.positionState),
],
@@ -356,6 +370,37 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
);
}
Widget _buildPositionStateRow(String label, PositionState? positionState) {
String value = '未知';
if (positionState != null) {
final fixedStatus = positionState.isFixed == 'fixing_successful'
? '已固定'
: '未固定';
value =
'GPS:${positionState.gpsNumber} RTX:${positionState.rtkNumber} 固定:$fixedStatus';
}
return Row(
children: [
Text(
label,
style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)),
),
const Spacer(),
Flexible(
child: Text(
value,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF1D2129),
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.right,
),
),
],
);
}
Widget _buildDroneStatusCard(UAVDetailEntity detail) {
return GestureDetector(
onTap: () {
@@ -644,8 +689,6 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
);
}
Widget _buildQuickActionButton({
required IconData icon,
required String label,
@@ -710,36 +753,6 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
);
}
Widget _buildPositionStateRow(String label, PositionState? positionState) {
String value = '未知';
if (positionState != null) {
value =
'GPS:${positionState.gpsNumber} RTX:${positionState.rtkNumber} 固定:${positionState.isFixed}';
}
return Row(
children: [
Text(
label,
style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)),
),
const Spacer(),
Flexible(
child: Text(
value,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF1D2129),
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.right,
),
),
],
);
}
Widget _buildCameraListRow(String label, List<CameraInfo> cameras) {
String value = cameras
.map((c) => '${c.cameraIndex}:${c.cameraPosition}')
@@ -766,4 +779,31 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
],
);
}
/// 格式化降雨量显示
String _formatRainfall(String? rainfall) {
if (rainfall == null || rainfall.isEmpty) {
return '未知';
}
// 特殊值映射
switch (rainfall.toLowerCase()) {
case 'no_rain':
return '无降雨';
case 'light_rain':
return '小雨';
case 'moderate_rain':
return '中雨';
case 'heavy_rain':
return '大雨';
default:
// 如果是数字,直接显示
try {
final value = double.parse(rainfall);
return '$value mm';
} catch (e) {
return rainfall;
}
}
}
}

View File

@@ -472,6 +472,7 @@ class _RobotListViewState extends State<RobotListView> {
return filteredRobots.map((robot) => RobotItemCard(
name: robot.name,
id: robot.id,
alias: robot.alias, // 传递别名
type: robot.type,
status: robot.status,
battery: robot.battery,
@@ -503,6 +504,7 @@ class _RobotListViewState extends State<RobotListView> {
final robotMap = {
'name': robot.name,
'id': robot.id,
'alias': robot.alias, // 传递别名
'type': robot.type,
'status': robot.status,
'battery': robot.battery,

View File

@@ -0,0 +1,543 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../../../core/di/injection.dart';
import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart';
import '../../../../../core/network/mqtt/domain/entities/drone_osd_entity.dart';
/// 无人机 OSD 实时数据卡片(网格布局展示所有数据)
class DroneOsdCard extends StatefulWidget {
final String deviceSn;
final String gatewaySn;
final bool isDroneOnline;
const DroneOsdCard({
super.key,
required this.deviceSn,
required this.gatewaySn,
required this.isDroneOnline,
});
@override
State<DroneOsdCard> createState() => _DroneOsdCardState();
}
class _DroneOsdCardState extends State<DroneOsdCard> {
late DroneOsdDataSource _dataSource;
StreamSubscription<DroneOsdEntity>? _subscription;
DroneOsdEntity? _currentOsd;
// OSD 数据字段列表
List<Map<String, dynamic>> _osdFields = [];
// 缓存上次的有效值,避免闪烁
Map<String, String> _cachedValues = {};
@override
void initState() {
super.initState();
_dataSource = sl<DroneOsdDataSource>();
_startListening();
}
@override
void didUpdateWidget(DroneOsdCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.deviceSn != widget.deviceSn ||
oldWidget.gatewaySn != widget.gatewaySn ||
oldWidget.isDroneOnline != widget.isDroneOnline) {
_stopListening();
_startListening();
}
}
@override
void dispose() {
_stopListening();
super.dispose();
}
void _startListening() {
if (!widget.isDroneOnline) return;
if (widget.deviceSn.isEmpty) return;
debugPrint('🔊 [DroneOsdCard] 开始监听无人机 OSD: ${widget.deviceSn}');
_dataSource.startListening(
deviceSn: widget.deviceSn,
gatewaySn: widget.gatewaySn,
);
_subscription = _dataSource.droneOsdStream.listen((osd) {
if (!mounted) return;
debugPrint('📥 [DroneOsdCard] 收到无人机 OSD 数据');
setState(() {
_currentOsd = osd;
_parseOsdFields(osd);
});
debugPrint('✅ [DroneOsdCard] UI 已更新,字段数: ${_osdFields.length}');
});
}
void _stopListening() {
_subscription?.cancel();
_subscription = null;
_dataSource.stopListening();
debugPrint('⏹️ [DroneOsdCard] 停止监听无人机 OSD');
}
/// 解析无人机 OSD 数据为可展示的字段列表
void _parseOsdFields(DroneOsdEntity osd) {
final data = osd.rawData;
// 解析嵌套的 JSON 结构
final droneData = data['data'] is Map
? (data['data'] as Map)['drone']
: null;
if (droneData == null || droneData is! Map) {
debugPrint('⚠️ [DroneOsdCard] 无法解析 drone 数据');
return;
}
debugPrint('📊 [DroneOsdCard] drone 数据键: ${droneData.keys.toList()}');
// ========== 1. 基础飞行信息 ==========
// 无人机高度(height)
double? height = (droneData['height'] as num?)?.toDouble();
// 飞行速度(ground_speed)
double? groundSpeed = (droneData['ground_speed'] as num?)?.toDouble();
// 飞行距离(flight_distance)
double? flightDistance = (droneData['flight_distance'] as num?)?.toDouble();
// 飞行时间(flight_time)
int? flightTime = droneData['flight_time'] as int?;
// ========== 2. 电池信息 ==========
// 电量百分比(battery_percent)
int? batteryPercent = droneData['battery_percent'] as int?;
// 电池电压(battery_voltage)
double? batteryVoltage = (droneData['battery_voltage'] as num?)?.toDouble();
// 电池电流(battery_current)
double? batteryCurrent = (droneData['battery_current'] as num?)?.toDouble();
// 电池温度(battery_temperature)
double? batteryTemp = (droneData['battery_temperature'] as num?)
?.toDouble();
// ========== 3. 位置与姿态 ==========
// 纬度(latitude)
double? latitude = (droneData['latitude'] as num?)?.toDouble();
// 经度(longitude)
double? longitude = (droneData['longitude'] as num?)?.toDouble();
// 航向角(heading)
double? heading = (droneData['heading'] as num?)?.toDouble();
// 俯仰角(pitch)
double? pitch = (droneData['pitch'] as num?)?.toDouble();
// 横滚角(roll)
double? roll = (droneData['roll'] as num?)?.toDouble();
// ========== 4. GPS 状态 ==========
// GPS卫星数(gps_satellites)
int? gpsSatellites = droneData['gps_satellites'] as int?;
// GPS信号质量(gps_quality)
int? gpsQuality = droneData['gps_quality'] as int?;
// ========== 5. 遥控信号 ==========
// 遥控信号强度(rc_signal_strength)
int? rcSignal = droneData['rc_signal_strength'] as int?;
// 图传信号强度(video_signal_strength)
int? videoSignal = droneData['video_signal_strength'] as int?;
// ========== 6. 飞行模式 ==========
// 飞行模式(flight_mode)
String? flightMode = droneData['flight_mode'] as String?;
// ========== 7. 电机状态 ==========
// 电机状态(motor_status)
int? motorStatus = droneData['motor_status'] as int?;
// ========== 8. 任务状态 ==========
// 任务进度(mission_progress)
int? missionProgress = droneData['mission_progress'] as int?;
// 航点数量(waypoint_count)
int? waypointCount = droneData['waypoint_count'] as int?;
// 当前航点(current_waypoint)
int? currentWaypoint = droneData['current_waypoint'] as int?;
// 剩余航点(remaining_waypoints)
int? remainingWaypoints = droneData['remaining_waypoints'] as int?;
debugPrint('✅ [DroneOsdCard] 解析结果:');
debugPrint(' 高度: $height m');
debugPrint(' 速度: $groundSpeed m/s');
debugPrint(' 距离: $flightDistance m');
debugPrint(' 时间: $flightTime s');
debugPrint(' 电量: $batteryPercent%');
debugPrint(' 电压: $batteryVoltage V');
debugPrint(' GPS: $gpsSatellites 颗卫星');
debugPrint(' 航向: $heading°');
debugPrint(' 飞行模式: $flightMode');
// 构建展示字段列表(精选重要字段)
_osdFields = [
{
'icon': Icons.height,
'label': '飞行高度',
'key': 'height',
'newValue': height != null ? '${height.toStringAsFixed(1)}m' : null,
'color': const Color(0xFF165DFF),
},
{
'icon': Icons.speed,
'label': '飞行速度',
'key': 'speed',
'newValue': groundSpeed != null
? '${groundSpeed.toStringAsFixed(1)}m/s'
: null,
'color': const Color(0xFF722ED1),
},
{
'icon': Icons.route,
'label': '飞行距离',
'key': 'distance',
'newValue': flightDistance != null
? '${flightDistance.toStringAsFixed(0)}m'
: null,
'color': const Color(0xFF00B42A),
},
{
'icon': Icons.timer,
'label': '飞行时间',
'key': 'time',
'newValue': flightTime != null
? '${(flightTime / 60).toStringAsFixed(1)}min'
: null,
'color': const Color(0xFF86909C),
},
{
'icon': Icons.battery_full_rounded,
'label': '电量',
'key': 'battery',
'newValue': batteryPercent != null ? '$batteryPercent%' : null,
'color': _getBatteryColor(batteryPercent),
},
{
'icon': Icons.bolt,
'label': '电池电压',
'key': 'voltage',
'newValue': batteryVoltage != null
? '${batteryVoltage.toStringAsFixed(1)}V'
: null,
'color': const Color(0xFF00B42A),
},
{
'icon': Icons.thermostat,
'label': '电池温度',
'key': 'batteryTemp',
'newValue': batteryTemp != null
? '${batteryTemp.toStringAsFixed(0)}°C'
: null,
'color': batteryTemp != null && batteryTemp > 50
? const Color(0xFFF53F3F)
: const Color(0xFF165DFF),
},
{
'icon': Icons.navigation,
'label': '航向角',
'key': 'heading',
'newValue': heading != null ? '${heading.toStringAsFixed(0)}°' : null,
'color': const Color(0xFF165DFF),
},
{
'icon': Icons.rotate_90_degrees_cw,
'label': '俯仰角',
'key': 'pitch',
'newValue': pitch != null ? '${pitch.toStringAsFixed(1)}°' : null,
'color': const Color(0xFF722ED1),
},
{
'icon': Icons.rotate_right,
'label': '横滚角',
'key': 'roll',
'newValue': roll != null ? '${roll.toStringAsFixed(1)}°' : null,
'color': const Color(0xFF722ED1),
},
{
'icon': Icons.satellite,
'label': 'GPS卫星',
'key': 'gps',
'newValue': gpsSatellites != null ? '$gpsSatellites颗' : null,
'color': gpsSatellites != null && gpsSatellites >= 6
? const Color(0xFF00B42A)
: const Color(0xFFFF7D00),
},
{
'icon': Icons.signal_cellular_alt,
'label': '遥控信号',
'key': 'rcSignal',
'newValue': rcSignal != null ? '$rcSignal%' : null,
'color': rcSignal != null && rcSignal > 80
? const Color(0xFF00B42A)
: rcSignal != null && rcSignal > 50
? const Color(0xFFFF7D00)
: const Color(0xFFF53F3F),
},
{
'icon': Icons.video_label,
'label': '图传信号',
'key': 'videoSignal',
'newValue': videoSignal != null ? '$videoSignal%' : null,
'color': videoSignal != null && videoSignal > 80
? const Color(0xFF00B42A)
: videoSignal != null && videoSignal > 50
? const Color(0xFFFF7D00)
: const Color(0xFFF53F3F),
},
{
'icon': Icons.flight,
'label': '飞行模式',
'key': 'flightMode',
'newValue': flightMode,
'color': const Color(0xFF165DFF),
},
{
'icon': Icons.radio_button_checked,
'label': '任务进度',
'key': 'mission',
'newValue': missionProgress != null ? '$missionProgress%' : null,
'color': const Color(0xFF00B42A),
},
{
'icon': Icons.map,
'label': '航点',
'key': 'waypoint',
'newValue': (currentWaypoint != null && waypointCount != null)
? '$currentWaypoint/$waypointCount'
: null,
'color': const Color(0xFF722ED1),
},
];
// 应用缓存逻辑:有新值则更新缓存,否则使用旧值
for (var field in _osdFields) {
final key = field['key'] as String;
final newValue = field['newValue'] as String?;
if (newValue != null && newValue != '未知' && newValue.isNotEmpty) {
_cachedValues[key] = newValue;
field['value'] = newValue;
} else {
field['value'] = _cachedValues[key] ?? '未知';
}
}
debugPrint('✅ [DroneOsdCard] 共解析 ${_osdFields.length} 个字段');
}
Color _getBatteryColor(dynamic battery) {
if (battery == null) return const Color(0xFF86909C);
final value = battery is num ? battery.toDouble() : 0.0;
if (value > 50) return const Color(0xFF00B42A);
if (value > 20) return const Color(0xFFFF7D00);
return const Color(0xFFF53F3F);
}
@override
Widget build(BuildContext context) {
if (!widget.isDroneOnline) {
return _buildOfflineCard();
}
if (_osdFields.isEmpty) {
return _buildLoadingCard();
}
return _buildOsdGridCard();
}
/// 离线状态卡片
Widget _buildOfflineCard() {
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),
),
],
),
child: Row(
children: [
Icon(Icons.flight_land, size: 48, color: const Color(0xFF86909C)),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
'无人机离线',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
),
SizedBox(height: 4),
Text(
'无人机实时数据不可用',
style: TextStyle(fontSize: 12, color: Color(0xFF86909C)),
),
],
),
),
],
),
);
}
/// 加载中卡片
Widget _buildLoadingCard() {
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),
),
],
),
child: const Center(
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
),
);
}
/// OSD 网格布局卡片(核心功能)
Widget _buildOsdGridCard() {
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),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题
Row(
children: [
Icon(Icons.flight, size: 20, color: const Color(0xFF165DFF)),
const SizedBox(width: 8),
const Text(
'无人机实时信息',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
),
],
),
const SizedBox(height: 12),
// 网格布局展示所有字段
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.85,
),
itemCount: _osdFields.length,
itemBuilder: (context, index) {
final field = _osdFields[index];
return _buildOsdGridItem(field);
},
),
],
),
);
}
/// OSD 网格项
Widget _buildOsdGridItem(Map<String, dynamic> field) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: (field['color'] as Color).withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: (field['color'] as Color).withOpacity(0.2),
width: 1,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 图标
Icon(
field['icon'] as IconData,
color: field['color'] as Color,
size: 24,
),
const SizedBox(height: 8),
// 标签
Text(
field['label'] as String,
style: const TextStyle(fontSize: 11, color: Color(0xFF86909C)),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// 数值
Text(
field['value'] as String,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: field['color'] as Color,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}

View File

@@ -114,13 +114,13 @@ class DroneStationItemCard extends StatelessWidget {
children: [
_buildInfoRowWithIcon(
Icons.tag_rounded,
'机场序列号',
'无人机序列号',
station.deviceSn,
),
const SizedBox(height: 10),
_buildInfoRowWithIcon(
Icons.router_rounded,
'网关序列号',
'机场序列号',
station.gatewaySn,
),
// if (station.latitude != null || station.longitude != null) ...[

View File

@@ -0,0 +1,569 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../../../core/di/injection.dart';
import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart';
import '../../../../../core/network/mqtt/domain/entities/drone_osd_entity.dart';
/// 无人机机场 OSD 实时数据卡片(网格布局展示所有数据)
class DroneStationOsdCard extends StatefulWidget {
final String gatewaySn;
final bool isOnline;
const DroneStationOsdCard({
super.key,
required this.gatewaySn,
required this.isOnline,
});
@override
State<DroneStationOsdCard> createState() => _DroneStationOsdCardState();
}
class _DroneStationOsdCardState extends State<DroneStationOsdCard> {
late DroneOsdDataSource _dataSource;
StreamSubscription<DroneOsdEntity>? _subscription;
DroneOsdEntity? _currentOsd;
// OSD 数据字段列表
List<Map<String, dynamic>> _osdFields = [];
// 🔥 缓存上次的有效值,避免闪烁
Map<String, String> _cachedValues = {};
@override
void initState() {
super.initState();
_dataSource = sl<DroneOsdDataSource>();
_startListening();
}
@override
void didUpdateWidget(DroneStationOsdCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.gatewaySn != widget.gatewaySn ||
oldWidget.isOnline != widget.isOnline) {
_stopListening();
_startListening();
}
}
@override
void dispose() {
_stopListening();
super.dispose();
}
void _startListening() {
if (!widget.isOnline) return;
//debugPrint('🔊 [DroneStationOsdCard] 开始监听机场 OSD: ${widget.gatewaySn}');
_dataSource.startListening(
deviceSn: '', // 机场不需要 deviceSn
gatewaySn: widget.gatewaySn,
);
_subscription = _dataSource.stationOsdStream.listen((osd) {
if (!mounted) return;
//debugPrint('📥 [DroneStationOsdCard] 收到机场 OSD 原始数据');
setState(() {
_currentOsd = osd;
_parseOsdFields(osd);
});
// debugPrint('✅ [DroneStationOsdCard] UI 已更新,字段数: ${_osdFields.length}');
});
}
void _stopListening() {
_subscription?.cancel();
_subscription = null;
_dataSource.stopListening();
debugPrint('⏹️ [DroneStationOsdCard] 停止监听机场 OSD');
}
/// 解析 OSD 数据为可展示的字段列表
void _parseOsdFields(DroneOsdEntity osd) {
final data = osd.rawData;
// 解析嵌套的 JSON 结构
final hostData = data['data'] is Map ? (data['data'] as Map)['host'] : null;
if (hostData == null || hostData is! Map) {
debugPrint('⚠️ [DroneStationOsdCard] 无法解析 host 数据');
return;
}
// debugPrint('📊 [DroneStationOsdCard] 开始解析 OSD 数据...');
//debugPrint('📊 [DroneStationOsdCard] host 数据键: ${hostData.keys.toList()}');
// ========== 1. 电池相关 ==========
// 提取无人机电量(从 drone_battery_maintenance_info.batteries[0].capacity_percent)
double? batteryPercent;
final batteryInfo = hostData['drone_battery_maintenance_info'];
if (batteryInfo is Map && batteryInfo['batteries'] is List) {
final batteries = batteryInfo['batteries'] as List;
if (batteries.isNotEmpty && batteries[0] is Map) {
batteryPercent = (batteries[0]['capacity_percent'] as num?)?.toDouble();
}
}
// 提取备用电池温度(从 backup_battery.temperature)
double? backupBatteryTemp;
final backupBattery = hostData['backup_battery'];
if (backupBattery is Map) {
backupBatteryTemp = (backupBattery['temperature'] as num?)?.toDouble();
}
// 提取备用电池电压(从 backup_battery.voltage)
int? backupBatteryVoltage;
if (backupBattery is Map) {
backupBatteryVoltage = backupBattery['voltage'] as int?;
}
// 提取备用电池开关状态(从 backup_battery.switch)
int? backupBatterySwitch;
if (backupBattery is Map) {
backupBatterySwitch = backupBattery['switch'] as int?;
}
// ========== 2. 电源相关 ==========
// 提取交流输入功率(acdc_power_input)
double? acdcPower = (hostData['acdc_power_input'] as num?)?.toDouble();
// 提取供电电压(electric_supply_voltage)
int? supplyVoltage = hostData['electric_supply_voltage'] as int?;
// 提取 PoE 链路状态(poe_link_status)
int? poeLinkStatus = hostData['poe_link_status'] as int?;
// 提取 PoE 输出功率(poe_power_output)
double? poePowerOutput = (hostData['poe_power_output'] as num?)?.toDouble();
// ========== 3. 部署与维护 ==========
// 提取部署模式(deployment_mode)
int? deploymentMode = hostData['deployment_mode'] as int?;
// 提取作业编号(job_number)
int? jobNumber = hostData['job_number'] as int?;
// 提取云台 holder 状态(gimbal_holder_state)
int? gimbalHolderState = hostData['gimbal_holder_state'] as int?;
// 提取维护状态(maintain_status)
String maintainStatus = '未知';
final maintainStatusData = hostData['maintain_status'];
if (maintainStatusData is Map && maintainStatusData['maintain_status_array'] is List) {
final statusArray = maintainStatusData['maintain_status_array'] as List;
if (statusArray.isNotEmpty && statusArray[0] is Map) {
final firstStatus = statusArray[0] as Map;
final state = firstStatus['state'];
final maintainType = firstStatus['last_maintain_type'];
maintainStatus = '状态:$state 类型:$maintainType';
}
}
// ========== 4. 位置与坐标 ==========
// 提取相对备降点信息(relative_alternate_land_point)
String landPointInfo = '未知';
final landPointData = hostData['relative_alternate_land_point'];
if (landPointData is Map) {
final lat = landPointData['latitude'];
final lon = landPointData['longitude'];
final safeHeight = landPointData['safe_land_height'];
final status = landPointData['status'];
landPointInfo = 'LAT:${lat?.toStringAsFixed(4)} LON:${lon?.toStringAsFixed(4)} H:${safeHeight}m S:$status';
}
// 提取自收敛坐标(self_converge_coordinate)
String convergeCoord = '未知';
final convergeData = hostData['self_converge_coordinate'];
if (convergeData is Map) {
final height = convergeData['height'];
convergeCoord = 'H:${height}m';
}
// ========== 5. 网络与通信 ==========
// 提取 SDR 上行质量(sdr.up_quality)
int? sdrUpQuality;
final sdrData = hostData['sdr'];
if (sdrData is Map) {
sdrUpQuality = sdrData['up_quality'] as int?;
}
// 提取 SDR 下行质量(sdr.down_quality)
int? sdrDownQuality;
if (sdrData is Map) {
sdrDownQuality = sdrData['down_quality'] as int?;
}
// 提取 SDR 频段(sdr.frequency_band)
double? sdrFreqBand;
if (sdrData is Map) {
sdrFreqBand = (sdrData['frequency_band'] as num?)?.toDouble();
}
// ========== 6. 其他关键指标 ==========
// 提取累计时间(acc_time)
int? accTime = hostData['acc_time'] as int?;
// 提取激活时间(activation_time)
int? activationTime = hostData['activation_time'] as int?;
// 提取倾斜角度(tilt_angle.value)
double? tiltAngle;
final tiltAngleData = hostData['tilt_angle'];
if (tiltAngleData is Map && tiltAngleData['valid'] == 1) {
tiltAngle = (tiltAngleData['value'] as num?)?.toDouble();
}
/*debugPrint('✅ [DroneStationOsdCard] 解析结果:');
debugPrint(' 无人机电量: $batteryPercent%');
debugPrint(' 备用电池: ${backupBatteryTemp}°C / ${backupBatteryVoltage}mV / 开关:$backupBatterySwitch');
debugPrint(' 交流功率: $acdcPower W');
debugPrint(' 供电电压: $supplyVoltage V');
debugPrint(' PoE状态: $poeLinkStatus / 功率: $poePowerOutput W');
debugPrint(' 部署模式: $deploymentMode / 作业号: $jobNumber');
debugPrint(' 云台状态: $gimbalHolderState');
debugPrint(' 维护状态: $maintainStatus');
debugPrint(' 备降点: $landPointInfo');
debugPrint(' SDR上行: $sdrUpQuality% / 下行: $sdrDownQuality% / 频段: $sdrFreqBand GHz');
debugPrint(' 倾斜角度: $tiltAngle°');
debugPrint(' 累计时间: $accTime s');*/
// 构建展示字段列表(精选重要字段)
// 🔥 使用缓存机制:有值则更新并缓存,无值则使用上次缓存的值
_osdFields = [
{
'icon': Icons.battery_full_rounded,
'label': '无人机电量',
'key': 'battery',
'newValue': batteryPercent != null ? '${batteryPercent.toInt()}%' : null,
'color': _getBatteryColor(batteryPercent),
},
{
'icon': Icons.thermostat_rounded,
'label': '备用电池温度',
'key': 'backupTemp',
'newValue': backupBatteryTemp != null ? '${backupBatteryTemp.toStringAsFixed(1)}°C' : null,
'color': const Color(0xFF165DFF),
},
{
'icon': Icons.bolt_rounded,
'label': '备用电池电压',
'key': 'backupVoltage',
'newValue': backupBatteryVoltage != null ? '${(backupBatteryVoltage / 1000).toStringAsFixed(2)}V' : null,
'color': const Color(0xFF722ED1),
},
{
'icon': Icons.power_rounded,
'label': '交流输入功率',
'key': 'acdcPower',
'newValue': acdcPower != null ? '${acdcPower.toStringAsFixed(1)} W' : null,
'color': const Color(0xFFFF7D00),
},
{
'icon': Icons.electrical_services_rounded,
'label': '供电电压',
'key': 'supplyVoltage',
'newValue': supplyVoltage != null ? '$supplyVoltage V' : null,
'color': const Color(0xFF00B42A),
},
{
'icon': Icons.network_check_rounded,
'label': 'PoE链路',
'key': 'poeLink',
'newValue': poeLinkStatus != null ? (poeLinkStatus == 1 ? '已连接' : '未连接') : null,
'color': poeLinkStatus == 1 ? const Color(0xFF00B42A) : const Color(0xFF86909C),
},
{
'icon': Icons.settings_rounded,
'label': '部署模式',
'key': 'deployMode',
'newValue': deploymentMode != null ? '模式$deploymentMode' : null,
'color': const Color(0xFF165DFF),
},
{
'icon': Icons.work_outline_rounded,
'label': '作业编号',
'key': 'jobNumber',
'newValue': jobNumber != null ? '#$jobNumber' : null,
'color': const Color(0xFF00B42A),
},
{
'icon': Icons.camera_roll_rounded,
'label': '云台状态',
'key': 'gimbalState',
'newValue': gimbalHolderState != null ? (gimbalHolderState == 1 ? '已锁定' : '未锁定') : null,
'color': gimbalHolderState == 1 ? const Color(0xFF00B42A) : const Color(0xFFF53F3F),
},
{
'icon': Icons.rotate_right_rounded,
'label': '倾斜角度',
'key': 'tiltAngle',
'newValue': tiltAngle != null ? '${tiltAngle.toStringAsFixed(2)}°' : null,
'color': const Color(0xFF722ED1),
},
{
'icon': Icons.signal_cellular_alt_rounded,
'label': 'SDR上行质量',
'key': 'sdrUp',
'newValue': sdrUpQuality != null ? '$sdrUpQuality%' : null,
'color': sdrUpQuality != null && sdrUpQuality > 80
? const Color(0xFF00B42A)
: sdrUpQuality != null && sdrUpQuality > 50
? const Color(0xFFFF7D00)
: const Color(0xFFF53F3F),
},
{
'icon': Icons.wifi_tethering_rounded,
'label': 'SDR下行质量',
'key': 'sdrDown',
'newValue': sdrDownQuality != null ? '$sdrDownQuality%' : null,
'color': sdrDownQuality != null && sdrDownQuality > 80
? const Color(0xFF00B42A)
: sdrDownQuality != null && sdrDownQuality > 50
? const Color(0xFFFF7D00)
: const Color(0xFFF53F3F),
},
{
'icon': Icons.access_time_rounded,
'label': '累计时间',
'key': 'accTime',
'newValue': accTime != null ? '${(accTime / 3600).toStringAsFixed(1)}h' : null,
'color': const Color(0xFF86909C),
},
{
'icon': Icons.calendar_today_rounded,
'label': '激活时间',
'key': 'activationTime',
'newValue': activationTime != null ? _formatTimestamp(activationTime) : null,
'color': const Color(0xFF86909C),
},
];
// 🔥 应用缓存逻辑:有新值则更新缓存,否则使用旧值
for (var field in _osdFields) {
final key = field['key'] as String;
final newValue = field['newValue'] as String?;
if (newValue != null && newValue != '未知') {
// 有新值,更新缓存
_cachedValues[key] = newValue;
field['value'] = newValue;
} else {
// 无新值,使用缓存值或默认"未知"
field['value'] = _cachedValues[key] ?? '未知';
}
}
//debugPrint('✅ [DroneStationOsdCard] 共解析 ${_osdFields.length} 个字段');
}
Color _getBatteryColor(dynamic battery) {
if (battery == null) return const Color(0xFF86909C);
final value = battery is num ? battery.toDouble() : 0.0;
if (value > 50) return const Color(0xFF00B42A);
if (value > 20) return const Color(0xFFFF7D00);
return const Color(0xFFF53F3F);
}
/// 格式化 Unix 时间戳(秒)为日期字符串
String _formatTimestamp(int timestamp) {
try {
final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}';
} catch (e) {
return '未知';
}
}
@override
Widget build(BuildContext context) {
if (!widget.isOnline) {
return _buildOfflineCard();
}
if (_osdFields.isEmpty) {
return _buildLoadingCard();
}
return _buildOsdGridCard();
}
/// 离线状态卡片
Widget _buildOfflineCard() {
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),
),
],
),
child: Row(
children: [
Icon(Icons.cloud_off_rounded, size: 48, color: const Color(0xFF86909C)),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
'机场离线',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
),
SizedBox(height: 4),
Text(
'暂无实时数据',
style: TextStyle(fontSize: 12, color: Color(0xFF86909C)),
),
],
),
),
],
),
);
}
/// 加载中卡片
Widget _buildLoadingCard() {
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),
),
],
),
child: const Center(
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
),
);
}
/// OSD 网格布局卡片(核心功能)
Widget _buildOsdGridCard() {
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),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题
Row(
children: [
Icon(Icons.analytics_rounded, size: 20, color: const Color(0xFF165DFF)),
const SizedBox(width: 8),
const Text(
'实时数据',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
),
],
),
const SizedBox(height: 12),
// 网格布局展示所有字段
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.85,
),
itemCount: _osdFields.length,
itemBuilder: (context, index) {
final field = _osdFields[index];
return _buildOsdGridItem(field);
},
),
],
),
);
}
/// OSD 网格项
Widget _buildOsdGridItem(Map<String, dynamic> field) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: (field['color'] as Color).withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: (field['color'] as Color).withOpacity(0.2),
width: 1,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 图标
Icon(
field['icon'] as IconData,
color: field['color'] as Color,
size: 24,
),
const SizedBox(height: 8),
// 标签
Text(
field['label'] as String,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF86909C),
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// 数值
Text(
field['value'] as String,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: field['color'] as Color,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}

View File

@@ -1,11 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart';
/// 机器人顶部信息卡片
class RobotHeaderCard extends StatelessWidget {
class RobotHeaderCard extends StatefulWidget {
final Map<String, dynamic> robot;
const RobotHeaderCard({super.key, required this.robot});
@override
State<RobotHeaderCard> createState() => _RobotHeaderCardState();
}
class _RobotHeaderCardState extends State<RobotHeaderCard> {
String _videoStreamUrl = '';
int _currentViewIndex = 0; // 0=前, 1=后, 2=左, 3=右, 4=上
// 视角配置
final List<Map<String, dynamic>> _viewConfigs = [
{'name': '前视', 'alignment': Alignment.topLeft, 'icon': Icons.arrow_upward},
{'name': '后视', 'alignment': Alignment.topRight, 'icon': Icons.arrow_downward},
{'name': '左视', 'alignment': Alignment.bottomLeft, 'icon': Icons.arrow_back},
{'name': '右视', 'alignment': Alignment.bottomRight, 'icon': Icons.arrow_forward},
{'name': '俯视', 'alignment': Alignment.center, 'icon': Icons.view_agenda},
];
@override
void initState() {
super.initState();
_initVideoUrl();
}
/// 初始化视频流 URL
void _initVideoUrl() {
final userState = context.read<AppUserCubit>().state;
final deviceId = widget.robot['id'] as String?;
debugPrint(' [RobotHeaderCard] 开始初始化视频URL');
debugPrint('🎬 [RobotHeaderCard] deviceId: $deviceId');
debugPrint('🎬 [RobotHeaderCard] user: ${userState.user}');
debugPrint('🎬 [RobotHeaderCard] token: ${userState.user?.token}');
if (deviceId != null && deviceId.isNotEmpty && userState.user != null && userState.user!.token != null) {
setState(() {
_videoStreamUrl = "webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=${userState.user!.token}";
});
debugPrint('✅ [RobotHeaderCard] 视频URL初始化成功: $_videoStreamUrl');
} else {
debugPrint('❌ [RobotHeaderCard] 视频URL初始化失败');
debugPrint(' - deviceId.isEmpty: ${deviceId == null || deviceId.isEmpty}');
debugPrint(' - user == null: ${userState.user == null}');
debugPrint(' - token == null: ${userState.user?.token == null}');
}
}
@override
Widget build(BuildContext context) {
return Container(
@@ -31,7 +81,7 @@ class RobotHeaderCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
robot['name'] as String,
widget.robot['name'] as String,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
@@ -40,7 +90,7 @@ class RobotHeaderCard extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
'ID: ${robot['id']}',
'ID: ${widget.robot['id']}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF86909C),
@@ -92,14 +142,81 @@ class RobotHeaderCard extends StatelessWidget {
],
),
const SizedBox(height: 12),
// 机器人图片
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
'assets/images/xunjian.png',
height: 180,
width: double.infinity,
fit: BoxFit.cover,
// 🔥 视频展示区域(替换原来的静态图片)
AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Colors.black, // 🔥 强制整个视频区域为黑色背景
child: _videoStreamUrl.isNotEmpty
? WebRTCLocalPlayer(
streamUrl: _videoStreamUrl,
showLeftPip: false, // 不显示悬浮小窗
showRightPip: false,
isFrontMain: _currentViewIndex == 0, // 根据当前视角决定主画面
)
: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.videocam_off, size: 48, color: Colors.white54),
const SizedBox(height: 12),
Text(
'无视频信号',
style: TextStyle(color: Colors.white70, fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
),
),
),
// 🔥 视角切换按钮
Padding(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: _viewConfigs.asMap().entries.map((entry) {
final index = entry.key;
final config = entry.value;
final isSelected = _currentViewIndex == index;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: ElevatedButton(
onPressed: () {
setState(() {
_currentViewIndex = index;
});
debugPrint('🎬 [RobotHeaderCard] 切换视角: ${config['name']}');
},
style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? const Color(0xFF165DFF) : Colors.grey[200],
foregroundColor: isSelected ? Colors.white : Colors.grey[700],
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(config['icon'] as IconData, size: 16),
const SizedBox(height: 2),
Text(
config['name'] as String,
style: const TextStyle(fontSize: 10),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
}).toList(),
),
),
],

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
class RobotItemCard extends StatelessWidget {
final String name;
final String id;
final String? alias; // 设备别名
final String type; // 机器人类型:巡检机器人、清洗机器人、除草机器人
final String status;
final double battery;
@@ -14,6 +15,7 @@ class RobotItemCard extends StatelessWidget {
super.key,
required this.name,
required this.id,
this.alias,
required this.type,
required this.status,
required this.battery,
@@ -81,7 +83,7 @@ class RobotItemCard extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
'ID: $id',
alias != null && alias!.isNotEmpty ? '别名: $alias' : 'ID: $id',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF86909C),