From b7ea788298bebf6a1c122d004b9750dc22773ae2 Mon Sep 17 00:00:00 2001 From: Songzex <2402265378@qq.com> Date: Tue, 23 Jun 2026 16:20:51 +0800 Subject: [PATCH] =?UTF-8?q?1=E4=BC=98=E5=8C=96=E6=94=B9=E5=8A=A8=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E6=A0=8F=E4=B8=AD=E5=85=A8=E9=83=A8=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E6=A0=8F=E7=9A=84=E8=AE=BE=E5=A4=87=E5=88=97=E8=A1=A8=E4=B8=AD?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9A=84=E6=95=B4=E5=90=88=EF=BC=8C=E5=8F=88?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=90=88=E5=B9=B6=E8=A7=A3=E8=80=A6=E6=88=90?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E5=B7=B2=E6=9C=89=E7=BB=84=E4=BB=B6=E5=90=84?= =?UTF-8?q?=E8=87=AA=E7=BB=B4=E6=8A=A4=EF=BC=8C=E4=BD=BF=E5=85=B6=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E5=92=8C=E9=80=BB=E8=BE=91=E4=B8=80=E8=87=B4=E3=80=82?= =?UTF-8?q?=202=E6=8E=A5=E5=85=A5=E4=BA=86=E6=97=A0=E4=BA=BA=E6=9C=BA?= =?UTF-8?q?=E6=9C=BA=E5=9C=BA=E7=9A=84=E5=AE=9E=E6=97=B6=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E5=92=8C=E6=97=A0=E4=BA=BA=E6=9C=BA=E7=9A=84=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=EF=BC=8C=E5=B9=B6=E5=88=9B=E5=BB=BA=E4=BA=86?= =?UTF-8?q?=E5=AF=B9=E5=BA=94=E7=9A=84=E7=BB=84=E4=BB=B6=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E3=80=82=203.=E5=AE=9E=E7=8E=B0=E4=BA=86?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E4=BA=BA=E7=9A=84=E8=AF=A6=E6=83=85=E9=A1=B5?= =?UTF-8?q?=E7=9A=84=E8=A7=86=E9=A2=91=E6=98=BE=E7=A4=BA=E7=9A=84=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E4=B8=BA=E5=BD=93=E6=9C=BA=E5=99=A8=E4=BA=BA=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=EF=BC=8C=E5=B0=B1=E5=9C=A8=E4=B8=8B=E6=96=B9=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=87=BA=E6=9D=A5=E6=9C=BA=E5=99=A8=E4=BA=BA=E7=9A=84?= =?UTF-8?q?=E5=90=84=E4=B8=AA=E6=96=B9=E4=BD=8D=E8=A7=86=E9=A2=91=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E4=B8=94=E6=94=AF=E6=8C=81=E5=88=87=E6=8D=A2=E3=80=82?= =?UTF-8?q?=204.=E5=AE=9E=E7=8E=B0=E4=BA=86=E5=85=A8=E9=83=A8=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E4=B8=AD=E5=AF=B9=E7=B1=BB=E5=9E=8B=E7=9A=84=E5=8C=BA?= =?UTF-8?q?=E5=88=86=E5=92=8C=E5=AE=9E=E7=8E=B0=E4=BA=86=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=E5=AF=B9=E5=BA=94=E7=9A=84=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=E3=80=82=205.=E4=BF=AE=E5=A4=8D=E4=BA=86=E9=83=A8?= =?UTF-8?q?=E5=88=86bug=EF=BC=8C=E4=BC=98=E5=8C=96=E4=BA=86=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=BB=93=E6=9E=84=EF=BC=8C=E6=8F=90=E9=AB=98=E4=BA=86?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=9A=84=E5=8F=AF=E8=AF=BB=E6=80=A7=E5=92=8C?= =?UTF-8?q?=E5=8F=AF=E7=BB=B4=E6=8A=A4=E6=80=A7=E3=80=82=206=E6=9B=B4?= =?UTF-8?q?=E6=94=B9=E4=BA=86=E6=9C=BA=E5=99=A8=E4=BA=BA=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E9=A1=B9=E4=B8=AD=E7=9A=84=E4=BF=A1=E6=81=AF=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E4=B8=BA=E6=9C=BA=E5=99=A8=E5=BA=8F=E5=88=97=E5=8F=B7=E5=92=8C?= =?UTF-8?q?=E5=88=AB=E5=90=8D=E5=92=8Cid=EF=BC=88=E4=BC=98=E5=85=88?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=88=AB=E5=90=8D=EF=BC=89=207.=E6=9B=B4?= =?UTF-8?q?=E6=94=B9=E4=BA=86=E6=97=A0=E4=BA=BA=E6=9C=BA=E5=9C=BA=E7=9A=84?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=B1=95=E7=A4=BA=E4=B8=8A=E4=BD=8D=20?= =?UTF-8?q?=E6=97=A0=E4=BA=BA=E6=9C=BA=E5=8F=8A=E5=BA=8F=E5=88=97=E5=8F=B7?= =?UTF-8?q?=E4=B8=8B=E4=BD=8D=E6=9C=BA=E5=9C=BA=E5=BA=8F=E5=88=97=E5=8F=B7?= =?UTF-8?q?=E3=80=82=208=E4=BC=98=E5=8C=96=E4=BA=86=E6=97=A0=E4=BA=BA?= =?UTF-8?q?=E6=9C=BA=E6=9C=BA=E5=9C=BA=E4=BF=A1=E6=81=AF=E5=8F=AA=E8=83=BD?= =?UTF-8?q?=E6=98=AF=E7=9A=84=E4=B8=BA=E6=98=8E=E6=96=87=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E3=80=82=E4=BE=BF=E4=BA=8E=E8=A7=82=E7=9C=8B=E7=90=86=E8=A7=A3?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/di/injection.dart | 24 +- .../datasources/drone_osd_datasource.dart | 47 +- .../datasources/task_message_datasource.dart | 26 +- .../data/infrastructure/mqtt_client_impl.dart | 248 +++++--- .../mqtt/domain/models/mqtt_config.dart | 37 +- lib/core/network/mqtt/mqtt_manager.dart | 35 +- .../pages/machine_details_page.dart | 162 ++++- .../widgets/webrtc/webrtc_local_player.dart | 53 +- .../data/models/robot_data_model.dart | 10 +- .../float_bar/bloc/float_bar_bloc.dart | 107 ++++ .../cubit/float_bar_setting_cubit.dart | 62 ++ .../float_bar/float_bar_controller.dart | 24 + .../float_bar/manager/float_bar_manager.dart | 69 +++ .../float_bar/model/robot_status_model.dart | 104 ++++ .../service/robot_status_service.dart | 101 ++++ .../float_bar/simple_float_bar.dart | 123 ++++ .../float_bar/view/float_bar_widget.dart | 350 +++++++++++ .../pages/device_status_page.dart | 324 ++++++++-- .../pages/drone_station_detail_page.dart | 216 ++++--- .../presentation/pages/robot_list_page.dart | 2 + .../presentation/widgets/drone_osd_card.dart | 543 +++++++++++++++++ .../widgets/drone_station_item_card.dart | 4 +- .../widgets/drone_station_osd_card.dart | 569 ++++++++++++++++++ .../widgets/robot_header_card.dart | 139 ++++- .../presentation/widgets/robot_item_card.dart | 4 +- 25 files changed, 3074 insertions(+), 309 deletions(-) create mode 100644 lib/features/v2/device_list/presentation/float_bar/bloc/float_bar_bloc.dart create mode 100644 lib/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart create mode 100644 lib/features/v2/device_list/presentation/float_bar/float_bar_controller.dart create mode 100644 lib/features/v2/device_list/presentation/float_bar/manager/float_bar_manager.dart create mode 100644 lib/features/v2/device_list/presentation/float_bar/model/robot_status_model.dart create mode 100644 lib/features/v2/device_list/presentation/float_bar/service/robot_status_service.dart create mode 100644 lib/features/v2/device_list/presentation/float_bar/simple_float_bar.dart create mode 100644 lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart create mode 100644 lib/features/v2/device_list/presentation/widgets/drone_osd_card.dart create mode 100644 lib/features/v2/device_list/presentation/widgets/drone_station_osd_card.dart diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index 1c9ab790..b003e34c 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -192,11 +192,14 @@ Future init() async { sl.registerLazySingleton(() => SentryLoggerImpl()); /// 1.4 --- MQTT Data Sources --- - sl.registerLazySingleton( - () => DroneOsdDataSourceImpl(sl(instanceName: 'droneOsdClient')), + sl.registerFactory( + () => + DroneOsdDataSourceImpl(sl(instanceName: 'droneOsdClient')), ); - sl.registerLazySingleton( - () => TaskMessageDataSourceImpl(sl(instanceName: 'taskMessageClient')), + sl.registerFactory( + () => TaskMessageDataSourceImpl( + sl(instanceName: 'taskMessageClient'), + ), ); /// 1.5 --- MQTT Repositories --- @@ -492,9 +495,7 @@ Future init() async { ); /// 创建设备任务(通过接口执行作业) - sl.registerLazySingleton( - () => CreateDeviceTaskUseCase(sl()), - ); + sl.registerLazySingleton(() => CreateDeviceTaskUseCase(sl())); /// 6. 认证 (Auth) // --- 关键修改点 1: AuthCubit 必须在 GoRouter 之前注册,并传入参数 --- @@ -546,12 +547,5 @@ Future 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())); } diff --git a/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart b/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart index 84289cfd..65cb96bf 100644 --- a/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart +++ b/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart @@ -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; 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'); diff --git a/lib/core/network/mqtt/data/datasources/task_message_datasource.dart b/lib/core/network/mqtt/data/datasources/task_message_datasource.dart index c975671b..de4ab186 100644 --- a/lib/core/network/mqtt/data/datasources/task_message_datasource.dart +++ b/lib/core/network/mqtt/data/datasources/task_message_datasource.dart @@ -21,7 +21,8 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource { final MqttClient mqttClient; final _taskStatusController = StreamController.broadcast(); final _taskArriveController = StreamController.broadcast(); - final _realTimeMessageController = StreamController.broadcast(); + final _realTimeMessageController = + StreamController.broadcast(); StreamSubscription? _subscription; String? _deviceId; @@ -40,6 +41,18 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource { @override Future 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) { diff --git a/lib/core/network/mqtt/data/infrastructure/mqtt_client_impl.dart b/lib/core/network/mqtt/data/infrastructure/mqtt_client_impl.dart index b5c17f28..69336a35 100644 --- a/lib/core/network/mqtt/data/infrastructure/mqtt_client_impl.dart +++ b/lib/core/network/mqtt/data/infrastructure/mqtt_client_impl.dart @@ -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.broadcast(); + final Map _subscriptionRefs = {}; + MqttConfig? _currentConfig; + StreamSubscription? _updatesSubscription; + Timer? _reconnectTimer; bool _isConnected = false; + bool _isConnecting = false; + bool _manualDisconnect = false; @override Stream? get messageStream => _messageController.stream; @@ -21,148 +28,239 @@ class MqttClientImpl implements MqttClient { @override Future 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 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 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 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 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> 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> 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(); } } diff --git a/lib/core/network/mqtt/domain/models/mqtt_config.dart b/lib/core/network/mqtt/domain/models/mqtt_config.dart index d6209209..3bfb0e7a 100644 --- a/lib/core/network/mqtt/domain/models/mqtt_config.dart +++ b/lib/core/network/mqtt/domain/models/mqtt_config.dart @@ -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, ]; } diff --git a/lib/core/network/mqtt/mqtt_manager.dart b/lib/core/network/mqtt/mqtt_manager.dart index f00fa4dd..8d4ef7f4 100644 --- a/lib/core/network/mqtt/mqtt_manager.dart +++ b/lib/core/network/mqtt/mqtt_manager.dart @@ -20,20 +20,31 @@ class MqttManager { return; } + debugPrint('🔧 [MqttManager] 开始初始化 MQTT 连接...'); + + final droneOsdClient = sl(instanceName: 'droneOsdClient'); + final taskMessageClient = sl(instanceName: 'taskMessageClient'); + + // 独立连接两个 MQTT 客户端,互不影响 + await _connectClient(droneOsdClient, MqttConfig.droneOsd(), 'droneOsdClient'); + await _connectClient(taskMessageClient, MqttConfig.taskMessage(), 'taskMessageClient'); + + _isInitialized = true; + debugPrint('✅ [MqttManager] MQTT 初始化完成'); + } + + Future _connectClient( + MqttClient client, + MqttConfig config, + String clientName, + ) async { try { - debugPrint('🔧 [MqttManager] 开始初始化 MQTT 连接...'); - - final droneOsdClient = sl(instanceName: 'droneOsdClient'); - final taskMessageClient = sl(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'); + // 不抛出异常,允许其他客户端继续连接 } } diff --git a/lib/features/machine_details/presentation/pages/machine_details_page.dart b/lib/features/machine_details/presentation/pages/machine_details_page.dart index d6ea72c9..a05a1e15 100644 --- a/lib/features/machine_details/presentation/pages/machine_details_page.dart +++ b/lib/features/machine_details/presentation/pages/machine_details_page.dart @@ -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 { // 🔥 保存 stream 引用,避免每次 rebuild 都重新获取 late Stream _deviceStatusStream; late DeviceStatusState _initialState; + + // 🔥 新增:视频相关状态 + String _videoStreamUrl = ''; + int _currentViewIndex = 0; // 0=前, 1=后, 2=左, 3=右, 4=上 + + // 视角配置 + final List> _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 { _deviceStatusStream = bloc.stream; _initialState = bloc.state; debugPrint('📦 [MachineDetails] initState - 初始状态: ${_initialState.runtimeType}'); + + // 🔥 初始化视频 URL + _initVideoUrl(); + } + + /// 🔥 初始化视频流 URL + void _initVideoUrl() { + final userState = context.read().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 { ); } - /// 设备状态卡片(保持不变) + /// 设备状态卡片(🔥 修改为视频展示 + 视角切换) 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 { ), 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(), ), ), ], diff --git a/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart b/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart index 0c467d51..b33b4643 100644 --- a/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart +++ b/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart @@ -163,14 +163,41 @@ class _WebRTCLocalPlayerState extends State { @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 { 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( diff --git a/lib/features/v2/device_list/data/models/robot_data_model.dart b/lib/features/v2/device_list/data/models/robot_data_model.dart index d13880fe..574ea938 100644 --- a/lib/features/v2/device_list/data/models/robot_data_model.dart +++ b/lib/features/v2/device_list/data/models/robot_data_model.dart @@ -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 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, diff --git a/lib/features/v2/device_list/presentation/float_bar/bloc/float_bar_bloc.dart b/lib/features/v2/device_list/presentation/float_bar/bloc/float_bar_bloc.dart new file mode 100644 index 00000000..31c94dd4 --- /dev/null +++ b/lib/features/v2/device_list/presentation/float_bar/bloc/float_bar_bloc.dart @@ -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 { + final RobotStatusService _statusService; + final FloatBarManager? _floatBarManager; + StreamSubscription? _statusSubscription; + + FloatBarBloc(this._statusService, [this._floatBarManager]) + : super(FloatBarCollapsedState(_statusService.currentStatus)) { + // 监听服务层数据流 + _startListening(); + + on(_handleToggleExpand); + on(_handleUpdateStatus); + } + + /// 开始监听服务层数据 + void _startListening() { + _statusSubscription?.cancel(); + _statusSubscription = _statusService.statusStream.listen((status) { + add(UpdateStatusEvent(status)); + }); + } + + /// 处理切换折叠/展开 + void _handleToggleExpand( + ToggleExpandEvent event, + Emitter 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 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 close() { + _statusSubscription?.cancel(); + _statusService.stop(); + return super.close(); + } +} diff --git a/lib/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart b/lib/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart new file mode 100644 index 00000000..c9f91841 --- /dev/null +++ b/lib/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart @@ -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 _settingNotifier = ValueNotifier(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 get settingNotifier => _settingNotifier; + + /// 获取当前是否启用(直接从静态变量读取) + static bool get isEnabled => _isEnabled; + + /// 设置是否启用 + static Future 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 toggle() async { + await setEnabled(!_isEnabled); + } +} diff --git a/lib/features/v2/device_list/presentation/float_bar/float_bar_controller.dart b/lib/features/v2/device_list/presentation/float_bar/float_bar_controller.dart new file mode 100644 index 00000000..c6e748b1 --- /dev/null +++ b/lib/features/v2/device_list/presentation/float_bar/float_bar_controller.dart @@ -0,0 +1,24 @@ +import 'package:flutter/foundation.dart'; + +/// 悬浮条控制器 - 极简版 +/// 使用静态变量管理全局状态 +class FloatBarController { + /// 🔥 是否显示悬浮条 + static bool isVisible = true; + + /// 🔥 状态变化通知器 + static final ValueNotifier visibilityNotifier = ValueNotifier(true); + + /// 设置显示/隐藏 + static void setVisible(bool visible) { + if (isVisible != visible) { + isVisible = visible; + visibilityNotifier.value = visible; + } + } + + /// 切换显示状态 + static void toggle() { + setVisible(!isVisible); + } +} diff --git a/lib/features/v2/device_list/presentation/float_bar/manager/float_bar_manager.dart b/lib/features/v2/device_list/presentation/float_bar/manager/float_bar_manager.dart new file mode 100644 index 00000000..ddb1e579 --- /dev/null +++ b/lib/features/v2/device_list/presentation/float_bar/manager/float_bar_manager.dart @@ -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; + } +} \ No newline at end of file diff --git a/lib/features/v2/device_list/presentation/float_bar/model/robot_status_model.dart b/lib/features/v2/device_list/presentation/float_bar/model/robot_status_model.dart new file mode 100644 index 00000000..27b06229 --- /dev/null +++ b/lib/features/v2/device_list/presentation/float_bar/model/robot_status_model.dart @@ -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 get props => [ + taskName, + battery, + status, + signal, + location, + speed, + temperature, + runTime, + ]; +} \ No newline at end of file diff --git a/lib/features/v2/device_list/presentation/float_bar/service/robot_status_service.dart b/lib/features/v2/device_list/presentation/float_bar/service/robot_status_service.dart new file mode 100644 index 00000000..ee2b576d --- /dev/null +++ b/lib/features/v2/device_list/presentation/float_bar/service/robot_status_service.dart @@ -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 _statusController = + StreamController.broadcast(); + + /// 当前状态 + RobotStatusModel _currentStatus = const RobotStatusModel(); + + /// 模拟定时器 + Timer? _timer; + + /// 状态数据流 + Stream 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(); + } +} \ No newline at end of file diff --git a/lib/features/v2/device_list/presentation/float_bar/simple_float_bar.dart b/lib/features/v2/device_list/presentation/float_bar/simple_float_bar.dart new file mode 100644 index 00000000..fb388e66 --- /dev/null +++ b/lib/features/v2/device_list/presentation/float_bar/simple_float_bar.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'float_bar_controller.dart'; + +/// 简单悬浮条组件 +class SimpleFloatBar extends StatefulWidget { + const SimpleFloatBar({super.key}); + + @override + State createState() => _SimpleFloatBarState(); +} + +class _SimpleFloatBarState extends State { + 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)), + ]); + } +} diff --git a/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart b/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart new file mode 100644 index 00000000..110fee43 --- /dev/null +++ b/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart @@ -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 createState() => _FloatBarWidgetState(); +} + +class _FloatBarWidgetState extends State { + 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( + 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().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; + } + } +} diff --git a/lib/features/v2/device_list/presentation/pages/device_status_page.dart b/lib/features/v2/device_list/presentation/pages/device_status_page.dart index 473b1832..37640893 100644 --- a/lib/features/v2/device_list/presentation/pages/device_status_page.dart +++ b/lib/features/v2/device_list/presentation/pages/device_status_page.dart @@ -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().state.selectedSite; + final siteId = selectedSite?.id; + + debugPrint('🔍 [EmbeddedRobotList] 开始加载机器人数据, siteId: $siteId'); + + return BlocProvider( + create: (_) => + sl()..add(RobotListLoadData(siteId: siteId)), + child: BlocConsumer( + 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().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(); + 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), + ), + ); + } + } } diff --git a/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart b/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart index d3155741..f94c3876 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart @@ -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 { UAVDetailEntity? _detail; String? _droneSn; - - // 无人机状态轮询计时器 Timer? _droneStatusPollingTimer; @@ -44,59 +44,59 @@ class _DroneStationDetailPageState extends State { ); // 启动无人机状态轮询(每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 { _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 { ), ); // 重置轮询计时器,使用新的状态 - _scheduleDroneStatusPoll(); + // 🔥 已禁用自动轮询,无需重置 + // _scheduleDroneStatusPoll(); } Widget _buildMonitorCard() { @@ -348,7 +362,7 @@ class _DroneStationDetailPageState extends State { '风速', 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 { ); } + 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 { ); } - - Widget _buildQuickActionButton({ required IconData icon, required String label, @@ -710,36 +753,6 @@ class _DroneStationDetailPageState extends State { ); } - 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 cameras) { String value = cameras .map((c) => '${c.cameraIndex}:${c.cameraPosition}') @@ -766,4 +779,31 @@ class _DroneStationDetailPageState extends State { ], ); } + + /// 格式化降雨量显示 + 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; + } + } + } } diff --git a/lib/features/v2/device_list/presentation/pages/robot_list_page.dart b/lib/features/v2/device_list/presentation/pages/robot_list_page.dart index 82ebd8f6..9a943650 100644 --- a/lib/features/v2/device_list/presentation/pages/robot_list_page.dart +++ b/lib/features/v2/device_list/presentation/pages/robot_list_page.dart @@ -472,6 +472,7 @@ class _RobotListViewState extends State { 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 { final robotMap = { 'name': robot.name, 'id': robot.id, + 'alias': robot.alias, // 传递别名 'type': robot.type, 'status': robot.status, 'battery': robot.battery, diff --git a/lib/features/v2/device_list/presentation/widgets/drone_osd_card.dart b/lib/features/v2/device_list/presentation/widgets/drone_osd_card.dart new file mode 100644 index 00000000..39937c8a --- /dev/null +++ b/lib/features/v2/device_list/presentation/widgets/drone_osd_card.dart @@ -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 createState() => _DroneOsdCardState(); +} + +class _DroneOsdCardState extends State { + late DroneOsdDataSource _dataSource; + StreamSubscription? _subscription; + DroneOsdEntity? _currentOsd; + + // OSD 数据字段列表 + List> _osdFields = []; + + // 缓存上次的有效值,避免闪烁 + Map _cachedValues = {}; + + @override + void initState() { + super.initState(); + _dataSource = sl(); + _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 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, + ), + ], + ), + ); + } +} diff --git a/lib/features/v2/device_list/presentation/widgets/drone_station_item_card.dart b/lib/features/v2/device_list/presentation/widgets/drone_station_item_card.dart index 1bd315c7..e8cb3839 100644 --- a/lib/features/v2/device_list/presentation/widgets/drone_station_item_card.dart +++ b/lib/features/v2/device_list/presentation/widgets/drone_station_item_card.dart @@ -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) ...[ diff --git a/lib/features/v2/device_list/presentation/widgets/drone_station_osd_card.dart b/lib/features/v2/device_list/presentation/widgets/drone_station_osd_card.dart new file mode 100644 index 00000000..14eee1f1 --- /dev/null +++ b/lib/features/v2/device_list/presentation/widgets/drone_station_osd_card.dart @@ -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 createState() => _DroneStationOsdCardState(); +} + +class _DroneStationOsdCardState extends State { + late DroneOsdDataSource _dataSource; + StreamSubscription? _subscription; + DroneOsdEntity? _currentOsd; + + // OSD 数据字段列表 + List> _osdFields = []; + + // 🔥 缓存上次的有效值,避免闪烁 + Map _cachedValues = {}; + + @override + void initState() { + super.initState(); + _dataSource = sl(); + _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 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, + ), + ], + ), + ); + } +} diff --git a/lib/features/v2/device_list/presentation/widgets/robot_header_card.dart b/lib/features/v2/device_list/presentation/widgets/robot_header_card.dart index 02750a3d..f3ded756 100644 --- a/lib/features/v2/device_list/presentation/widgets/robot_header_card.dart +++ b/lib/features/v2/device_list/presentation/widgets/robot_header_card.dart @@ -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 robot; const RobotHeaderCard({super.key, required this.robot}); + @override + State createState() => _RobotHeaderCardState(); +} + +class _RobotHeaderCardState extends State { + String _videoStreamUrl = ''; + int _currentViewIndex = 0; // 0=前, 1=后, 2=左, 3=右, 4=上 + + // 视角配置 + final List> _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().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(), ), ), ], diff --git a/lib/features/v2/device_list/presentation/widgets/robot_item_card.dart b/lib/features/v2/device_list/presentation/widgets/robot_item_card.dart index 251a04dd..f915eb89 100644 --- a/lib/features/v2/device_list/presentation/widgets/robot_item_card.dart +++ b/lib/features/v2/device_list/presentation/widgets/robot_item_card.dart @@ -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),