1优化改动设备栏中全部标签栏的设备列表中数据的整合,又数据合并解耦成使用已有组件各自维护,使其样式和逻辑一致。
2接入了无人机机场的实时信息和无人机的实时信息,并创建了对应的组件,并测试。 3.实现了机器人的详情页的视频显示的逻辑为当机器人在线,就在下方同步出来机器人的各个方位视频,并且支持切换。 4.实现了全部设备中对类型的区分和实现了点击进入对应的详情页。 5.修复了部分bug,优化了代码结构,提高了代码的可读性和可维护性。 6更改了机器人列表项中的信息展示为机器序列号和别名和id(优先展示别名) 7.更改了无人机场的信息展示上位 无人机及序列号下位机场序列号。 8优化了无人机机场信息只能是的为明文展示。便于观看理解。
This commit is contained in:
@@ -192,11 +192,14 @@ Future<void> init() async {
|
||||
sl.registerLazySingleton<ILoggerService>(() => SentryLoggerImpl());
|
||||
|
||||
/// 1.4 --- MQTT Data Sources ---
|
||||
sl.registerLazySingleton<DroneOsdDataSource>(
|
||||
() => DroneOsdDataSourceImpl(sl<MqttClient>(instanceName: 'droneOsdClient')),
|
||||
sl.registerFactory<DroneOsdDataSource>(
|
||||
() =>
|
||||
DroneOsdDataSourceImpl(sl<MqttClient>(instanceName: 'droneOsdClient')),
|
||||
);
|
||||
sl.registerLazySingleton<TaskMessageDataSource>(
|
||||
() => TaskMessageDataSourceImpl(sl<MqttClient>(instanceName: 'taskMessageClient')),
|
||||
sl.registerFactory<TaskMessageDataSource>(
|
||||
() => TaskMessageDataSourceImpl(
|
||||
sl<MqttClient>(instanceName: 'taskMessageClient'),
|
||||
),
|
||||
);
|
||||
|
||||
/// 1.5 --- MQTT Repositories ---
|
||||
@@ -492,9 +495,7 @@ Future<void> init() async {
|
||||
);
|
||||
|
||||
/// 创建设备任务(通过接口执行作业)
|
||||
sl.registerLazySingleton(
|
||||
() => CreateDeviceTaskUseCase(sl<PathRepository>()),
|
||||
);
|
||||
sl.registerLazySingleton(() => CreateDeviceTaskUseCase(sl<PathRepository>()));
|
||||
|
||||
/// 6. 认证 (Auth)
|
||||
// --- 关键修改点 1: AuthCubit 必须在 GoRouter 之前注册,并传入参数 ---
|
||||
@@ -546,12 +547,5 @@ Future<void> init() async {
|
||||
sl.registerLazySingleton(() => CancelTaskUseCase(sl()));
|
||||
sl.registerLazySingleton(() => PauseTaskUseCase(sl()));
|
||||
sl.registerLazySingleton(() => RecoveryTaskUseCase(sl()));
|
||||
sl.registerFactory(
|
||||
() => DeviceTaskCubit(
|
||||
sl(),
|
||||
sl(),
|
||||
sl(),
|
||||
sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory(() => DeviceTaskCubit(sl(), sl(), sl(), sl()));
|
||||
}
|
||||
|
||||
@@ -40,6 +40,17 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
|
||||
required String deviceSn,
|
||||
required String gatewaySn,
|
||||
}) async {
|
||||
if (_deviceSn == deviceSn &&
|
||||
_gatewaySn == gatewaySn &&
|
||||
_subscription != null) {
|
||||
debugPrint('[DroneOsdDataSource] already listening');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_deviceSn != null || _gatewaySn != null) {
|
||||
await stopListening();
|
||||
}
|
||||
|
||||
_deviceSn = deviceSn;
|
||||
_gatewaySn = gatewaySn;
|
||||
|
||||
@@ -50,8 +61,12 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
|
||||
debugPrint(' 无人机: $droneTopic');
|
||||
debugPrint(' 机场: $stationTopic');
|
||||
|
||||
await mqttClient.subscribe(droneTopic);
|
||||
await mqttClient.subscribe(stationTopic);
|
||||
if (deviceSn.isNotEmpty) {
|
||||
await mqttClient.subscribe(droneTopic);
|
||||
}
|
||||
if (gatewaySn.isNotEmpty) {
|
||||
await mqttClient.subscribe(stationTopic);
|
||||
}
|
||||
|
||||
_subscription = mqttClient.messageStream?.listen((message) {
|
||||
_handleMessage(message);
|
||||
@@ -63,11 +78,13 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
|
||||
if (_deviceSn != null) {
|
||||
await mqttClient.unsubscribe('thing/product/$_deviceSn/osd');
|
||||
if (_deviceSn != null && _deviceSn!.isNotEmpty) {
|
||||
final deviceSn = _deviceSn!;
|
||||
await mqttClient.unsubscribe('thing/product/$deviceSn/osd');
|
||||
}
|
||||
if (_gatewaySn != null) {
|
||||
await mqttClient.unsubscribe('thing/product/$_gatewaySn/osd');
|
||||
if (_gatewaySn != null && _gatewaySn!.isNotEmpty) {
|
||||
final gatewaySn = _gatewaySn!;
|
||||
await mqttClient.unsubscribe('thing/product/$gatewaySn/osd');
|
||||
}
|
||||
|
||||
_deviceSn = null;
|
||||
@@ -79,12 +96,22 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
|
||||
final jsonData = jsonDecode(message.payload) as Map<String, dynamic>;
|
||||
final osdData = DroneOsdEntity.fromJson(jsonData);
|
||||
|
||||
if (message.topic.contains(_deviceSn ?? '')) {
|
||||
debugPrint('🛸 [DroneOsdDataSource] 无人机 OSD 更新');
|
||||
_droneOsdController.add(osdData);
|
||||
} else if (message.topic.contains(_gatewaySn ?? '')) {
|
||||
// 🔥 重要修复:先判断 gatewaySn,再判断 deviceSn
|
||||
// 因为 topic 可能同时包含两者,但我们需要优先匹配机场
|
||||
if (_gatewaySn != null &&
|
||||
_gatewaySn!.isNotEmpty &&
|
||||
message.topic.contains(_gatewaySn!)) {
|
||||
debugPrint('🏢 [DroneOsdDataSource] 机场 OSD 更新');
|
||||
debugPrint('🏢 [DroneOsdDataSource] Topic: ${message.topic}');
|
||||
_stationOsdController.add(osdData);
|
||||
} else if (_deviceSn != null &&
|
||||
_deviceSn!.isNotEmpty &&
|
||||
message.topic.contains(_deviceSn!)) {
|
||||
debugPrint('🛸 [DroneOsdDataSource] 无人机 OSD 更新');
|
||||
debugPrint('🛸 [DroneOsdDataSource] Topic: ${message.topic}');
|
||||
_droneOsdController.add(osdData);
|
||||
} else {
|
||||
debugPrint('⚠️ [DroneOsdDataSource] 未知设备类型,Topic: ${message.topic}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [DroneOsdDataSource] 解析 OSD 数据失败: $e');
|
||||
|
||||
@@ -21,7 +21,8 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
|
||||
final MqttClient mqttClient;
|
||||
final _taskStatusController = StreamController<TaskStatusEntity>.broadcast();
|
||||
final _taskArriveController = StreamController<TaskArriveEntity>.broadcast();
|
||||
final _realTimeMessageController = StreamController<RealTimeMessageEntity>.broadcast();
|
||||
final _realTimeMessageController =
|
||||
StreamController<RealTimeMessageEntity>.broadcast();
|
||||
|
||||
StreamSubscription<MqttMessage>? _subscription;
|
||||
String? _deviceId;
|
||||
@@ -40,6 +41,18 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
|
||||
|
||||
@override
|
||||
Future<void> startListening({required String deviceId}) async {
|
||||
if (_deviceId == deviceId && _subscription != null) {
|
||||
debugPrint('[TaskMessageDataSource] already listening: $deviceId');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_deviceId != null && _deviceId != deviceId) {
|
||||
await stopListening();
|
||||
} else {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
}
|
||||
|
||||
_deviceId = deviceId;
|
||||
|
||||
final taskStatusTopic = 'task/$deviceId/status';
|
||||
@@ -66,9 +79,10 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
|
||||
_subscription = null;
|
||||
|
||||
if (_deviceId != null) {
|
||||
await mqttClient.unsubscribe('task/$_deviceId/status');
|
||||
await mqttClient.unsubscribe('task/$_deviceId/arrive');
|
||||
await mqttClient.unsubscribe('device/$_deviceId/realTimeMessage');
|
||||
final deviceId = _deviceId!;
|
||||
await mqttClient.unsubscribe('task/$deviceId/status');
|
||||
await mqttClient.unsubscribe('task/$deviceId/arrive');
|
||||
await mqttClient.unsubscribe('device/$deviceId/realTimeMessage');
|
||||
}
|
||||
|
||||
_deviceId = null;
|
||||
@@ -88,7 +102,9 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
|
||||
_taskArriveController.add(arriveInfo);
|
||||
} else if (message.topic.contains('/realTimeMessage')) {
|
||||
final realTimeMsg = RealTimeMessageEntity.fromJson(jsonData);
|
||||
debugPrint('💬 [TaskMessageDataSource] 实时消息 - 类型: ${realTimeMsg.type}, 数据点数: ${realTimeMsg.data.length}');
|
||||
debugPrint(
|
||||
'💬 [TaskMessageDataSource] 实时消息 - 类型: ${realTimeMsg.type}, 数据点数: ${realTimeMsg.data.length}',
|
||||
);
|
||||
_realTimeMessageController.add(realTimeMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:mqtt_client/mqtt_client.dart' as mqtt;
|
||||
import 'package:mqtt_client/mqtt_server_client.dart' as mqtt_server;
|
||||
@@ -10,8 +11,14 @@ import '../../domain/models/mqtt_message.dart';
|
||||
class MqttClientImpl implements MqttClient {
|
||||
mqtt_server.MqttServerClient? _client;
|
||||
final _messageController = StreamController<MqttMessage>.broadcast();
|
||||
final Map<String, int> _subscriptionRefs = <String, int>{};
|
||||
|
||||
MqttConfig? _currentConfig;
|
||||
StreamSubscription? _updatesSubscription;
|
||||
Timer? _reconnectTimer;
|
||||
bool _isConnected = false;
|
||||
bool _isConnecting = false;
|
||||
bool _manualDisconnect = false;
|
||||
|
||||
@override
|
||||
Stream<MqttMessage>? get messageStream => _messageController.stream;
|
||||
@@ -21,148 +28,239 @@ class MqttClientImpl implements MqttClient {
|
||||
|
||||
@override
|
||||
Future<void> connect(MqttConfig config) async {
|
||||
if (_isConnecting) {
|
||||
debugPrint('[MqttClient] connect ignored, already connecting');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isConnected) {
|
||||
debugPrint('⚠️ [MqttClient] 已连接,先断开');
|
||||
debugPrint('[MqttClient] already connected, disconnect first');
|
||||
await disconnect();
|
||||
}
|
||||
|
||||
_manualDisconnect = false;
|
||||
_currentConfig = config;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
_isConnecting = true;
|
||||
|
||||
try {
|
||||
_currentConfig = config;
|
||||
final clientId = '${config.clientId}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
switch (config.protocol) {
|
||||
case MqttProtocol.websocket:
|
||||
_client = mqtt_server.MqttServerClient.withPort(
|
||||
config.host,
|
||||
clientId,
|
||||
config.port,
|
||||
);
|
||||
_client!.useWebSocket = true;
|
||||
break;
|
||||
case MqttProtocol.wss:
|
||||
_client = mqtt_server.MqttServerClient.withPort(
|
||||
config.host,
|
||||
clientId,
|
||||
config.port,
|
||||
);
|
||||
_client!.useWebSocket = true;
|
||||
_client!.secure = true;
|
||||
break;
|
||||
case MqttProtocol.tcp:
|
||||
default:
|
||||
_client = mqtt_server.MqttServerClient.withPort(
|
||||
config.host,
|
||||
clientId,
|
||||
config.port,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
_client!.logging(on: false);
|
||||
_client!.keepAlivePeriod = config.keepAlivePeriod;
|
||||
_client!.autoReconnect = true;
|
||||
_client!.resubscribeOnAutoReconnect = true;
|
||||
_client!.onDisconnected = _onDisconnected;
|
||||
_client!.onConnected = _onConnected;
|
||||
_client!.onSubscribed = _onSubscribed;
|
||||
|
||||
final connMessage = mqtt.MqttConnectMessage()
|
||||
.withClientIdentifier(clientId)
|
||||
.startClean()
|
||||
.withWillQos(mqtt.MqttQos.atLeastOnce);
|
||||
|
||||
_client!.connectionMessage = connMessage;
|
||||
|
||||
debugPrint('🔌 [MqttClient] 开始连接到 ${config.host}:${config.port}');
|
||||
await _updatesSubscription?.cancel();
|
||||
_updatesSubscription = null;
|
||||
_client = _createClient(config);
|
||||
_configureClient(_client!, config);
|
||||
|
||||
debugPrint('[MqttClient] connecting to ${config.connectionAddress}');
|
||||
await _client!.connect(config.username, config.password);
|
||||
|
||||
if (_client!.connectionStatus?.state == mqtt.MqttConnectionState.connected) {
|
||||
if (_client!.connectionStatus?.state ==
|
||||
mqtt.MqttConnectionState.connected) {
|
||||
_isConnected = true;
|
||||
debugPrint('✅ [MqttClient] 连接成功');
|
||||
debugPrint('[MqttClient] connected');
|
||||
_listenToMessages();
|
||||
_resubscribeAll();
|
||||
} else {
|
||||
throw Exception('连接失败: ${_client!.connectionStatus?.returnCode}');
|
||||
throw Exception(
|
||||
'MQTT connect failed: ${_client!.connectionStatus?.returnCode}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MqttClient] 连接异常: $e');
|
||||
debugPrint('[MqttClient] connect error: $e');
|
||||
_isConnected = false;
|
||||
_isConnecting = false;
|
||||
_scheduleReconnect();
|
||||
rethrow;
|
||||
} finally {
|
||||
_isConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
mqtt_server.MqttServerClient _createClient(MqttConfig config) {
|
||||
final clientId =
|
||||
'${config.clientId}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
switch (config.protocol) {
|
||||
case MqttProtocol.websocket:
|
||||
case MqttProtocol.wss:
|
||||
final client = mqtt_server.MqttServerClient.withPort(
|
||||
config.connectionAddress,
|
||||
clientId,
|
||||
config.port,
|
||||
);
|
||||
client.useWebSocket = true;
|
||||
client.websocketProtocols = ['mqtt'];
|
||||
return client;
|
||||
case MqttProtocol.tcp:
|
||||
return mqtt_server.MqttServerClient.withPort(
|
||||
config.host,
|
||||
clientId,
|
||||
config.port,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _configureClient(
|
||||
mqtt_server.MqttServerClient client,
|
||||
MqttConfig config,
|
||||
) {
|
||||
client.logging(on: false);
|
||||
client.keepAlivePeriod = config.keepAlivePeriod;
|
||||
client.autoReconnect = false;
|
||||
client.resubscribeOnAutoReconnect = false;
|
||||
client.onDisconnected = _onDisconnected;
|
||||
client.onConnected = _onConnected;
|
||||
client.onSubscribed = _onSubscribed;
|
||||
|
||||
var connMessage = mqtt.MqttConnectMessage()
|
||||
.withClientIdentifier(client.clientIdentifier)
|
||||
.withWillQos(mqtt.MqttQos.atLeastOnce);
|
||||
|
||||
if (config.cleanSession) {
|
||||
connMessage = connMessage.startClean();
|
||||
}
|
||||
|
||||
client.connectionMessage = connMessage;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
debugPrint('🔌 [MqttClient] 断开连接');
|
||||
debugPrint('[MqttClient] disconnect');
|
||||
_manualDisconnect = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
await _updatesSubscription?.cancel();
|
||||
_updatesSubscription = null;
|
||||
_client?.disconnect();
|
||||
_client = null;
|
||||
_isConnected = false;
|
||||
_isConnecting = false;
|
||||
_currentConfig = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> subscribe(String topic) async {
|
||||
final previousRefs = _subscriptionRefs[topic] ?? 0;
|
||||
_subscriptionRefs[topic] = previousRefs + 1;
|
||||
|
||||
if (!_isConnected || _client == null) {
|
||||
throw Exception('MQTT 未连接');
|
||||
debugPrint('[MqttClient] queued subscription while disconnected: $topic');
|
||||
_scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('📡 [MqttClient] 订阅主题: $topic');
|
||||
if (previousRefs > 0) {
|
||||
debugPrint('[MqttClient] subscription already active: $topic');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('[MqttClient] subscribe: $topic');
|
||||
_client!.subscribe(topic, mqtt.MqttQos.atLeastOnce);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unsubscribe(String topic) async {
|
||||
if (!_isConnected || _client == null) {
|
||||
throw Exception('MQTT 未连接');
|
||||
final currentRefs = _subscriptionRefs[topic] ?? 0;
|
||||
if (currentRefs <= 1) {
|
||||
_subscriptionRefs.remove(topic);
|
||||
} else {
|
||||
_subscriptionRefs[topic] = currentRefs - 1;
|
||||
debugPrint('[MqttClient] subscription still in use: $topic');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🔕 [MqttClient] 取消订阅: $topic');
|
||||
if (!_isConnected || _client == null) {
|
||||
debugPrint('[MqttClient] removed queued subscription: $topic');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('[MqttClient] unsubscribe: $topic');
|
||||
_client!.unsubscribe(topic);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> publish(String topic, String message) async {
|
||||
if (!_isConnected || _client == null) {
|
||||
throw Exception('MQTT 未连接');
|
||||
throw Exception('MQTT is not connected');
|
||||
}
|
||||
|
||||
debugPrint('📤 [MqttClient] 发布消息到 $topic');
|
||||
debugPrint('[MqttClient] publish: $topic');
|
||||
final builder = mqtt.MqttClientPayloadBuilder();
|
||||
builder.addString(message);
|
||||
_client!.publishMessage(topic, mqtt.MqttQos.atLeastOnce, builder.payload!);
|
||||
}
|
||||
|
||||
void _listenToMessages() {
|
||||
_client!.updates!.listen((List<mqtt.MqttReceivedMessage<mqtt.MqttMessage>> messages) {
|
||||
for (final msg in messages) {
|
||||
final topic = msg.topic;
|
||||
final payload = mqtt.MqttPublishPayload.bytesToStringAsString((msg.payload as mqtt.MqttPublishMessage).payload.message);
|
||||
_updatesSubscription = _client!.updates!.listen(
|
||||
(List<mqtt.MqttReceivedMessage<mqtt.MqttMessage>> messages) {
|
||||
for (final msg in messages) {
|
||||
final topic = msg.topic;
|
||||
final payload = mqtt.MqttPublishPayload.bytesToStringAsString(
|
||||
(msg.payload as mqtt.MqttPublishMessage).payload.message,
|
||||
);
|
||||
|
||||
debugPrint('📥 [MqttClient] 收到消息 [$topic]: $payload');
|
||||
debugPrint('[MqttClient] received [$topic]: $payload');
|
||||
_messageController.add(MqttMessage(topic: topic, payload: payload));
|
||||
}
|
||||
},
|
||||
onError: (Object error) {
|
||||
debugPrint('[MqttClient] updates stream error: $error');
|
||||
_isConnected = false;
|
||||
_scheduleReconnect();
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
}
|
||||
|
||||
_messageController.add(MqttMessage(
|
||||
topic: topic,
|
||||
payload: payload,
|
||||
));
|
||||
}
|
||||
});
|
||||
void _resubscribeAll() {
|
||||
if (_subscriptionRefs.isEmpty || _client == null) return;
|
||||
|
||||
for (final topic in _subscriptionRefs.keys) {
|
||||
debugPrint('[MqttClient] resubscribe: $topic');
|
||||
_client!.subscribe(topic, mqtt.MqttQos.atLeastOnce);
|
||||
}
|
||||
}
|
||||
|
||||
void _onConnected() {
|
||||
debugPrint('✅ [MqttClient] 已连接');
|
||||
debugPrint('[MqttClient] connected callback');
|
||||
_isConnected = true;
|
||||
}
|
||||
|
||||
void _onDisconnected() {
|
||||
debugPrint('❌ [MqttClient] 已断开');
|
||||
debugPrint('[MqttClient] disconnected callback');
|
||||
_isConnected = false;
|
||||
if (!_manualDisconnect) {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSubscribed(String topic) {
|
||||
debugPrint('✅ [MqttClient] 订阅成功: $topic');
|
||||
debugPrint('[MqttClient] subscribed: $topic');
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
final config = _currentConfig;
|
||||
if (_manualDisconnect || config == null || _isConnected || _isConnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_reconnectTimer?.isActive ?? false) return;
|
||||
|
||||
final delay = Duration(milliseconds: config.reconnectDelayMs);
|
||||
debugPrint('[MqttClient] reconnect scheduled in ${delay.inMilliseconds}ms');
|
||||
_reconnectTimer = Timer(delay, () async {
|
||||
if (_manualDisconnect || _isConnected || _isConnecting) return;
|
||||
|
||||
try {
|
||||
debugPrint('[MqttClient] reconnecting...');
|
||||
await connect(config);
|
||||
} catch (e) {
|
||||
debugPrint('[MqttClient] reconnect failed: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_messageController.close();
|
||||
disconnect();
|
||||
_messageController.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -20,20 +20,31 @@ class MqttManager {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🔧 [MqttManager] 开始初始化 MQTT 连接...');
|
||||
|
||||
final droneOsdClient = sl<MqttClient>(instanceName: 'droneOsdClient');
|
||||
final taskMessageClient = sl<MqttClient>(instanceName: 'taskMessageClient');
|
||||
|
||||
// 独立连接两个 MQTT 客户端,互不影响
|
||||
await _connectClient(droneOsdClient, MqttConfig.droneOsd(), 'droneOsdClient');
|
||||
await _connectClient(taskMessageClient, MqttConfig.taskMessage(), 'taskMessageClient');
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('✅ [MqttManager] MQTT 初始化完成');
|
||||
}
|
||||
|
||||
Future<void> _connectClient(
|
||||
MqttClient client,
|
||||
MqttConfig config,
|
||||
String clientName,
|
||||
) async {
|
||||
try {
|
||||
debugPrint('🔧 [MqttManager] 开始初始化 MQTT 连接...');
|
||||
|
||||
final droneOsdClient = sl<MqttClient>(instanceName: 'droneOsdClient');
|
||||
final taskMessageClient = sl<MqttClient>(instanceName: 'taskMessageClient');
|
||||
|
||||
await droneOsdClient.connect(MqttConfig.droneOsd());
|
||||
await taskMessageClient.connect(MqttConfig.taskMessage());
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('✅ [MqttManager] MQTT 初始化完成');
|
||||
debugPrint('🔌 [MqttManager] 连接 $clientName...');
|
||||
await client.connect(config);
|
||||
debugPrint('✅ [MqttManager] $clientName 连接成功');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MqttManager] MQTT 初始化失败: $e');
|
||||
rethrow;
|
||||
debugPrint('❌ [MqttManager] $clientName 连接失败: $e');
|
||||
// 不抛出异常,允许其他客户端继续连接
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
|
||||
import 'package:maibu_satabot_v2/core/router/route_paths.dart';
|
||||
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart';
|
||||
@@ -11,6 +12,7 @@ import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_stat
|
||||
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart';
|
||||
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_state.dart';
|
||||
import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_cubit.dart'; // 🔥 添加 RemoteControlCubit
|
||||
import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart';
|
||||
|
||||
class MachineDetailsPage extends StatefulWidget {
|
||||
final DeviceEntity? device;
|
||||
@@ -34,6 +36,19 @@ class _MachineDetailsPageState extends State<MachineDetailsPage> {
|
||||
// 🔥 保存 stream 引用,避免每次 rebuild 都重新获取
|
||||
late Stream<DeviceStatusState> _deviceStatusStream;
|
||||
late DeviceStatusState _initialState;
|
||||
|
||||
// 🔥 新增:视频相关状态
|
||||
String _videoStreamUrl = '';
|
||||
int _currentViewIndex = 0; // 0=前, 1=后, 2=左, 3=右, 4=上
|
||||
|
||||
// 视角配置
|
||||
final List<Map<String, dynamic>> _viewConfigs = [
|
||||
{'name': '前视', 'alignment': Alignment.topLeft, 'icon': Icons.arrow_upward},
|
||||
{'name': '后视', 'alignment': Alignment.topRight, 'icon': Icons.arrow_downward},
|
||||
{'name': '左视', 'alignment': Alignment.bottomLeft, 'icon': Icons.arrow_back},
|
||||
{'name': '右视', 'alignment': Alignment.bottomRight, 'icon': Icons.arrow_forward},
|
||||
{'name': '俯视', 'alignment': Alignment.center, 'icon': Icons.view_agenda},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -47,6 +62,30 @@ class _MachineDetailsPageState extends State<MachineDetailsPage> {
|
||||
_deviceStatusStream = bloc.stream;
|
||||
_initialState = bloc.state;
|
||||
debugPrint('📦 [MachineDetails] initState - 初始状态: ${_initialState.runtimeType}');
|
||||
|
||||
// 🔥 初始化视频 URL
|
||||
_initVideoUrl();
|
||||
}
|
||||
|
||||
/// 🔥 初始化视频流 URL
|
||||
void _initVideoUrl() {
|
||||
final userState = context.read<AppUserCubit>().state;
|
||||
debugPrint('🎬 [MachineDetails] 开始初始化视频URL');
|
||||
debugPrint('🎬 [MachineDetails] deviceId: $_deviceId');
|
||||
debugPrint('🎬 [MachineDetails] user: ${userState.user}');
|
||||
debugPrint('🎬 [MachineDetails] token: ${userState.user?.token}');
|
||||
|
||||
if (_deviceId.isNotEmpty && userState.user != null && userState.user!.token != null) {
|
||||
setState(() {
|
||||
_videoStreamUrl = "webrtc://${TCPConsts.TCP_IP}/live/livestream/$_deviceId?token=${userState.user!.token}";
|
||||
});
|
||||
debugPrint('✅ [MachineDetails] 视频URL初始化成功: $_videoStreamUrl');
|
||||
} else {
|
||||
debugPrint('❌ [MachineDetails] 视频URL初始化失败');
|
||||
debugPrint(' - deviceId.isEmpty: ${_deviceId.isEmpty}');
|
||||
debugPrint(' - user == null: ${userState.user == null}');
|
||||
debugPrint(' - token == null: ${userState.user?.token == null}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -292,10 +331,9 @@ class _MachineDetailsPageState extends State<MachineDetailsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 设备状态卡片(保持不变)
|
||||
/// 设备状态卡片(🔥 修改为视频展示 + 视角切换)
|
||||
Widget _buildDeviceStatusCard(DeviceEntity device) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -303,24 +341,112 @@ class _MachineDetailsPageState extends State<MachineDetailsPage> {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/car.png',
|
||||
width: 300,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(Icons.device_hub, size: 100, color: Colors.grey);
|
||||
},
|
||||
// 🔥 视频展示区域
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: _videoStreamUrl.isNotEmpty
|
||||
? WebRTCLocalPlayer(
|
||||
streamUrl: _videoStreamUrl,
|
||||
showLeftPip: false, // 不显示悬浮小窗
|
||||
showRightPip: false,
|
||||
isFrontMain: _currentViewIndex == 0, // 根据当前视角决定主画面
|
||||
)
|
||||
: Container(
|
||||
color: Colors.black87,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.videocam_off, size: 64, color: Colors.white54),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'视频未加载',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'deviceId: $_deviceId',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'URL: ${_videoStreamUrl.isEmpty ? "空" : "已设置"}',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: device.isOnline ? const Color(0xFF00C853).withOpacity(0.1) : const Color(0xFF999999).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
||||
// 🔥 在线状态标签
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: device.isOnline ? const Color(0xFF00C853).withOpacity(0.1) : const Color(0xFF999999).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
device.isOnline ? AppLocalizations.of(context).translate('home.device_online') : AppLocalizations.of(context).translate('home.device_offline'),
|
||||
style: TextStyle(fontSize: 14, color: device.isOnline ? const Color(0xFF00C853) : const Color(0xFF999999)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
device.isOnline ? AppLocalizations.of(context).translate('home.device_online') : AppLocalizations.of(context).translate('home.device_offline'),
|
||||
style: TextStyle(fontSize: 14, color: device.isOnline ? const Color(0xFF00C853) : const Color(0xFF999999)),
|
||||
),
|
||||
|
||||
// 🔥 视角切换按钮
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: _viewConfigs.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final config = entry.value;
|
||||
final isSelected = _currentViewIndex == index;
|
||||
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_currentViewIndex = index;
|
||||
});
|
||||
debugPrint('🎬 [MachineDetails] 切换视角: ${config['name']}');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSelected ? const Color(0xFF165DFF) : Colors.grey[200],
|
||||
foregroundColor: isSelected ? Colors.white : Colors.grey[700],
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
minimumSize: const Size(0, 36),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(config['icon'] as IconData, size: 16),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
config['name'] as String,
|
||||
style: const TextStyle(fontSize: 10),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -163,14 +163,41 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isInitialized)
|
||||
return const Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
// 🔥 如果 URL 为空且未初始化,返回黑色占位符
|
||||
if (!_isInitialized && widget.streamUrl.isEmpty) {
|
||||
return Container(
|
||||
color: Colors.black, // 🔥 使用黑色背景
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'无视频信号',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 14),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🔥 如果正在初始化但有 URL,显示加载指示器(使用黑色背景)
|
||||
if (!_isInitialized) {
|
||||
return Container(
|
||||
color: Colors.black, // 🔥 使用黑色背景
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(color: Colors.white),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'视频加载中...',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
backgroundColor: Colors.black, // 🔥 使用黑色背景
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double pipW = constraints.maxWidth / 5;
|
||||
@@ -191,20 +218,8 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// ① 背景虚化层(背景两侧模糊,模拟视觉聚焦)
|
||||
Positioned.fill(
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildQuadrantView(alignment: widget.isFrontMain ? Alignment.topLeft : Alignment.topRight),
|
||||
Positioned.fill(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(color: Colors.black.withOpacity(0.55)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 🔥 已移除背景虚化层,避免遮挡上方内容
|
||||
// 主视频层的羽化效果由 _buildMainViewWithFeathering() 处理
|
||||
|
||||
// ② 主视频层(🔥 去掉双击,交给外部控制)
|
||||
Center(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
class RobotDataModel {
|
||||
final String name;
|
||||
final String id;
|
||||
final String? alias; // 设备别名
|
||||
final String type;
|
||||
final String status;
|
||||
final double battery;
|
||||
@@ -10,6 +11,7 @@ class RobotDataModel {
|
||||
const RobotDataModel({
|
||||
required this.name,
|
||||
required this.id,
|
||||
this.alias,
|
||||
required this.type,
|
||||
required this.status,
|
||||
required this.battery,
|
||||
@@ -18,13 +20,16 @@ class RobotDataModel {
|
||||
|
||||
/// 从 JSON 创建数据模型
|
||||
factory RobotDataModel.fromJson(Map<String, dynamic> json) {
|
||||
final batteryValue = (json['battery'] as num?)?.toDouble() ??
|
||||
(json['capacity_percent'] as num?)?.toDouble() ?? 100.0;
|
||||
|
||||
return RobotDataModel(
|
||||
name: json['deviceName'] ?? json['name'] ?? '',
|
||||
id: json['deviceId']?.toString() ?? json['id']?.toString() ?? '',
|
||||
alias: json['deviceAlias'] as String?, // 获取别名字段
|
||||
type: json['deviceTypeName'] ?? json['type'] ?? '未知类型',
|
||||
status: _getStatusText(json['status'] ?? 0),
|
||||
battery: (json['battery'] as num?)?.toDouble() ??
|
||||
(json['capacity_percent'] as num?)?.toDouble() ?? 0.0,
|
||||
battery: batteryValue,
|
||||
task: json['task'] ?? json['currentTask'] ?? '待机中',
|
||||
);
|
||||
}
|
||||
@@ -34,6 +39,7 @@ class RobotDataModel {
|
||||
return {
|
||||
'name': name,
|
||||
'id': id,
|
||||
'alias': alias,
|
||||
'type': type,
|
||||
'status': status,
|
||||
'battery': battery,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import '../model/robot_status_model.dart';
|
||||
import '../service/robot_status_service.dart';
|
||||
import '../manager/float_bar_manager.dart';
|
||||
|
||||
/// 悬浮条事件
|
||||
abstract class FloatBarEvent {}
|
||||
|
||||
/// 切换折叠/展开状态事件
|
||||
class ToggleExpandEvent extends FloatBarEvent {}
|
||||
|
||||
/// 更新状态数据事件
|
||||
class UpdateStatusEvent extends FloatBarEvent {
|
||||
final RobotStatusModel status;
|
||||
|
||||
UpdateStatusEvent(this.status);
|
||||
}
|
||||
|
||||
/// 悬浮条状态
|
||||
abstract class FloatBarState {}
|
||||
|
||||
/// 折叠状态
|
||||
class FloatBarCollapsedState extends FloatBarState {
|
||||
final RobotStatusModel status;
|
||||
|
||||
FloatBarCollapsedState(this.status);
|
||||
}
|
||||
|
||||
/// 展开状态
|
||||
class FloatBarExpandedState extends FloatBarState {
|
||||
final RobotStatusModel status;
|
||||
|
||||
FloatBarExpandedState(this.status);
|
||||
}
|
||||
|
||||
/// 悬浮条Bloc
|
||||
class FloatBarBloc extends Bloc<FloatBarEvent, FloatBarState> {
|
||||
final RobotStatusService _statusService;
|
||||
final FloatBarManager? _floatBarManager;
|
||||
StreamSubscription? _statusSubscription;
|
||||
|
||||
FloatBarBloc(this._statusService, [this._floatBarManager])
|
||||
: super(FloatBarCollapsedState(_statusService.currentStatus)) {
|
||||
// 监听服务层数据流
|
||||
_startListening();
|
||||
|
||||
on<ToggleExpandEvent>(_handleToggleExpand);
|
||||
on<UpdateStatusEvent>(_handleUpdateStatus);
|
||||
}
|
||||
|
||||
/// 开始监听服务层数据
|
||||
void _startListening() {
|
||||
_statusSubscription?.cancel();
|
||||
_statusSubscription = _statusService.statusStream.listen((status) {
|
||||
add(UpdateStatusEvent(status));
|
||||
});
|
||||
}
|
||||
|
||||
/// 处理切换折叠/展开
|
||||
void _handleToggleExpand(
|
||||
ToggleExpandEvent event,
|
||||
Emitter<FloatBarState> emit,
|
||||
) {
|
||||
final currentState = state;
|
||||
if (currentState is FloatBarCollapsedState) {
|
||||
emit(FloatBarExpandedState(currentState.status));
|
||||
} else if (currentState is FloatBarExpandedState) {
|
||||
emit(FloatBarCollapsedState(currentState.status));
|
||||
}
|
||||
// 触发UI刷新(使用 ?. 处理空安全)
|
||||
_floatBarManager?.refresh();
|
||||
}
|
||||
|
||||
/// 处理状态数据更新
|
||||
void _handleUpdateStatus(
|
||||
UpdateStatusEvent event,
|
||||
Emitter<FloatBarState> emit,
|
||||
) {
|
||||
final currentState = state;
|
||||
if (currentState is FloatBarCollapsedState) {
|
||||
emit(FloatBarCollapsedState(event.status));
|
||||
} else if (currentState is FloatBarExpandedState) {
|
||||
emit(FloatBarExpandedState(event.status));
|
||||
}
|
||||
// 触发UI刷新(使用 ?. 处理空安全)
|
||||
_floatBarManager?.refresh();
|
||||
}
|
||||
|
||||
/// 启动状态服务
|
||||
void startService() {
|
||||
_statusService.start();
|
||||
}
|
||||
|
||||
/// 停止状态服务
|
||||
void stopService() {
|
||||
_statusService.stop();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_statusSubscription?.cancel();
|
||||
_statusService.stop();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 悬浮条设置服务 - 简化版
|
||||
/// 使用静态变量存储状态,确保全局同步
|
||||
class FloatBarSettingService {
|
||||
final SharedPreferences _prefs;
|
||||
static const String _key = 'float_bar_enabled';
|
||||
|
||||
/// 🔥 静态实例引用
|
||||
static FloatBarSettingService? _instance;
|
||||
|
||||
/// 🔥 静态状态变量 - 所有组件共享
|
||||
static bool _isEnabled = true;
|
||||
|
||||
/// 🔥 静态 ValueNotifier - 用于通知UI变化
|
||||
static final ValueNotifier<bool> _settingNotifier = ValueNotifier<bool>(true);
|
||||
|
||||
FloatBarSettingService(this._prefs) {
|
||||
_instance = this;
|
||||
// 从持久化读取初始状态
|
||||
_isEnabled = _prefs.getBool(_key) ?? true;
|
||||
_settingNotifier.value = _isEnabled;
|
||||
print('✅ [FloatBarSettingService] 初始化完成,初始状态: $_isEnabled');
|
||||
}
|
||||
|
||||
/// 获取静态实例
|
||||
static FloatBarSettingService? get instance => _instance;
|
||||
|
||||
/// 获取 ValueNotifier
|
||||
static ValueNotifier<bool> get settingNotifier => _settingNotifier;
|
||||
|
||||
/// 获取当前是否启用(直接从静态变量读取)
|
||||
static bool get isEnabled => _isEnabled;
|
||||
|
||||
/// 设置是否启用
|
||||
static Future<void> setEnabled(bool enabled) async {
|
||||
print('🔍 [FloatBarSettingService] setEnabled 被调用,新值: $enabled');
|
||||
|
||||
// 1. 更新静态变量
|
||||
_isEnabled = enabled;
|
||||
print('🔍 [FloatBarSettingService] 静态变量已更新: $_isEnabled');
|
||||
|
||||
// 2. 更新 ValueNotifier(通知所有监听者)
|
||||
_settingNotifier.value = enabled;
|
||||
print(
|
||||
'🔍 [FloatBarSettingService] ValueNotifier 已更新: ${_settingNotifier.value}',
|
||||
);
|
||||
|
||||
// 3. 持久化到 SharedPreferences
|
||||
final instance = _instance;
|
||||
if (instance != null) {
|
||||
await instance._prefs.setBool(_key, enabled);
|
||||
print('🔍 [FloatBarSettingService] 已保存到 SharedPreferences');
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换开关
|
||||
static Future<void> toggle() async {
|
||||
await setEnabled(!_isEnabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 悬浮条控制器 - 极简版
|
||||
/// 使用静态变量管理全局状态
|
||||
class FloatBarController {
|
||||
/// 🔥 是否显示悬浮条
|
||||
static bool isVisible = true;
|
||||
|
||||
/// 🔥 状态变化通知器
|
||||
static final ValueNotifier<bool> visibilityNotifier = ValueNotifier<bool>(true);
|
||||
|
||||
/// 设置显示/隐藏
|
||||
static void setVisible(bool visible) {
|
||||
if (isVisible != visible) {
|
||||
isVisible = visible;
|
||||
visibilityNotifier.value = visible;
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换显示状态
|
||||
static void toggle() {
|
||||
setVisible(!isVisible);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// 机器人状态数据模型
|
||||
class RobotStatusModel extends Equatable {
|
||||
/// 任务名称
|
||||
final String taskName;
|
||||
|
||||
/// 电量百分比
|
||||
final int battery;
|
||||
|
||||
/// 设备状态:idle/running/charging/error
|
||||
final String status;
|
||||
|
||||
/// 信号强度
|
||||
final int signal;
|
||||
|
||||
/// 当前位置
|
||||
final String location;
|
||||
|
||||
/// 速度
|
||||
final double speed;
|
||||
|
||||
/// 温度
|
||||
final int temperature;
|
||||
|
||||
/// 运行时间
|
||||
final String runTime;
|
||||
|
||||
const RobotStatusModel({
|
||||
this.taskName = '未知任务',
|
||||
this.battery = 100,
|
||||
this.status = 'idle',
|
||||
this.signal = 100,
|
||||
this.location = '未知位置',
|
||||
this.speed = 0.0,
|
||||
this.temperature = 25,
|
||||
this.runTime = '00:00:00',
|
||||
});
|
||||
|
||||
/// 创建副本
|
||||
RobotStatusModel copyWith({
|
||||
String? taskName,
|
||||
int? battery,
|
||||
String? status,
|
||||
int? signal,
|
||||
String? location,
|
||||
double? speed,
|
||||
int? temperature,
|
||||
String? runTime,
|
||||
}) {
|
||||
return RobotStatusModel(
|
||||
taskName: taskName ?? this.taskName,
|
||||
battery: battery ?? this.battery,
|
||||
status: status ?? this.status,
|
||||
signal: signal ?? this.signal,
|
||||
location: location ?? this.location,
|
||||
speed: speed ?? this.speed,
|
||||
temperature: temperature ?? this.temperature,
|
||||
runTime: runTime ?? this.runTime,
|
||||
);
|
||||
}
|
||||
|
||||
/// 状态描述文本
|
||||
String get statusText {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return '运行中';
|
||||
case 'charging':
|
||||
return '充电中';
|
||||
case 'error':
|
||||
return '故障';
|
||||
case 'idle':
|
||||
default:
|
||||
return '待机';
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态颜色
|
||||
String get statusColor {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return '#00C853';
|
||||
case 'charging':
|
||||
return '#03DAC6';
|
||||
case 'error':
|
||||
return '#FF5252';
|
||||
case 'idle':
|
||||
default:
|
||||
return '#9E9E9E';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
taskName,
|
||||
battery,
|
||||
status,
|
||||
signal,
|
||||
location,
|
||||
speed,
|
||||
temperature,
|
||||
runTime,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import '../model/robot_status_model.dart';
|
||||
|
||||
/// 机器人状态服务
|
||||
/// 负责模拟设备状态推送,实际项目中应替换为真实的TCP/接口对接
|
||||
class RobotStatusService {
|
||||
static final RobotStatusService _instance = RobotStatusService._internal();
|
||||
|
||||
factory RobotStatusService() => _instance;
|
||||
|
||||
RobotStatusService._internal();
|
||||
|
||||
/// 状态数据流控制器
|
||||
final StreamController<RobotStatusModel> _statusController =
|
||||
StreamController.broadcast();
|
||||
|
||||
/// 当前状态
|
||||
RobotStatusModel _currentStatus = const RobotStatusModel();
|
||||
|
||||
/// 模拟定时器
|
||||
Timer? _timer;
|
||||
|
||||
/// 状态数据流
|
||||
Stream<RobotStatusModel> get statusStream => _statusController.stream;
|
||||
|
||||
/// 获取当前状态
|
||||
RobotStatusModel get currentStatus => _currentStatus;
|
||||
|
||||
/// 启动状态推送
|
||||
void start() {
|
||||
if (_timer != null) return;
|
||||
|
||||
// 立即发送初始状态
|
||||
_statusController.add(_currentStatus);
|
||||
|
||||
// 模拟每3秒更新一次状态
|
||||
_timer = Timer.periodic(const Duration(seconds: 3), (timer) {
|
||||
_simulateStatusUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
/// 停止状态推送
|
||||
void stop() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
/// 手动更新状态(用于外部触发更新)
|
||||
void updateStatus(RobotStatusModel status) {
|
||||
_currentStatus = status;
|
||||
_statusController.add(status);
|
||||
}
|
||||
|
||||
/// 模拟状态更新
|
||||
void _simulateStatusUpdate() {
|
||||
final random = Random();
|
||||
final statuses = ['idle', 'running', 'charging', 'error'];
|
||||
|
||||
_currentStatus = _currentStatus.copyWith(
|
||||
battery: max(0, _currentStatus.battery + random.nextInt(3) - 1),
|
||||
status: random.nextDouble() > 0.95 ? statuses[random.nextInt(statuses.length)] : _currentStatus.status,
|
||||
signal: min(100, max(0, _currentStatus.signal + random.nextInt(5) - 2)),
|
||||
speed: _currentStatus.status == 'running' ? random.nextDouble() * 5 : 0,
|
||||
temperature: min(50, max(20, _currentStatus.temperature + random.nextInt(3) - 1)),
|
||||
runTime: _updateRunTime(),
|
||||
);
|
||||
|
||||
_statusController.add(_currentStatus);
|
||||
}
|
||||
|
||||
/// 更新运行时间
|
||||
String _updateRunTime() {
|
||||
if (_currentStatus.status != 'running') {
|
||||
return _currentStatus.runTime;
|
||||
}
|
||||
|
||||
final parts = _currentStatus.runTime.split(':');
|
||||
int hours = int.parse(parts[0]);
|
||||
int minutes = int.parse(parts[1]);
|
||||
int seconds = int.parse(parts[2]);
|
||||
|
||||
seconds++;
|
||||
if (seconds >= 60) {
|
||||
seconds = 0;
|
||||
minutes++;
|
||||
}
|
||||
if (minutes >= 60) {
|
||||
minutes = 0;
|
||||
hours++;
|
||||
}
|
||||
|
||||
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 释放资源
|
||||
void dispose() {
|
||||
stop();
|
||||
_statusController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'float_bar_controller.dart';
|
||||
|
||||
/// 简单悬浮条组件
|
||||
class SimpleFloatBar extends StatefulWidget {
|
||||
const SimpleFloatBar({super.key});
|
||||
|
||||
@override
|
||||
State<SimpleFloatBar> createState() => _SimpleFloatBarState();
|
||||
}
|
||||
|
||||
class _SimpleFloatBarState extends State<SimpleFloatBar> {
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _isExpanded = !_isExpanded),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
height: _isExpanded ? 200 : 56,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFE8F5E9), Color(0xFFFFFFFF)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.green.withOpacity(0.2), width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.12),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
spreadRadius: 2,
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: _isExpanded
|
||||
? SingleChildScrollView(child: _buildExpanded())
|
||||
: _buildCollapsed(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCollapsed() {
|
||||
return Row(children: [
|
||||
Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.green, borderRadius: BorderRadius.circular(5))),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(child: Text('设备运行中', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.black87))),
|
||||
const SizedBox(width: 12),
|
||||
Row(children: const [Icon(Icons.battery_full, size: 18, color: Colors.grey), SizedBox(width: 4), Text('85%', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500))]),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.keyboard_arrow_up, size: 20, color: Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
_buildCloseButton(),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildExpanded() {
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.green, borderRadius: BorderRadius.circular(5))),
|
||||
const SizedBox(width: 8),
|
||||
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration(color: Colors.green.withOpacity(0.1), borderRadius: BorderRadius.circular(4)), child: const Text('运行中', style: TextStyle(fontSize: 12, color: Colors.green, fontWeight: FontWeight.w500))),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(child: Text('设备运行中', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Colors.black87))),
|
||||
const Icon(Icons.keyboard_arrow_down, size: 20, color: Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
_buildCloseButton(),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [
|
||||
_infoItem(Icons.battery_full, '电量', '85%'),
|
||||
_infoItem(Icons.signal_cellular_alt, '信号', '100%'),
|
||||
_infoItem(Icons.speed, '速度', '0.0m/s'),
|
||||
_infoItem(Icons.thermostat, '温度', '25°C'),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: const [
|
||||
Icon(Icons.location_on, size: 14, color: Colors.grey),
|
||||
SizedBox(width: 4),
|
||||
Expanded(child: Text('北京市朝阳区', style: TextStyle(fontSize: 12, color: Colors.grey))),
|
||||
SizedBox(width: 12),
|
||||
Icon(Icons.timer, size: 14, color: Colors.grey),
|
||||
SizedBox(width: 4),
|
||||
Text('02:35:18', style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildCloseButton() {
|
||||
return GestureDetector(
|
||||
onTap: () => FloatBarController.setVisible(false),
|
||||
child: Container(padding: const EdgeInsets.all(4), decoration: BoxDecoration(color: Colors.grey.withOpacity(0.1), borderRadius: BorderRadius.circular(8)), child: const Icon(Icons.close, size: 16, color: Colors.grey)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoItem(IconData icon, String label, String value) {
|
||||
return Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(icon, size: 20, color: Colors.grey),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: Colors.grey)),
|
||||
const SizedBox(height: 2),
|
||||
Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../bloc/float_bar_bloc.dart';
|
||||
import '../model/robot_status_model.dart';
|
||||
import '../service/robot_status_service.dart';
|
||||
import '../cubit/float_bar_setting_cubit.dart';
|
||||
|
||||
/// 悬浮条UI组件
|
||||
/// 内部独立管理Bloc,不依赖外部注入
|
||||
class FloatBarWidget extends StatefulWidget {
|
||||
const FloatBarWidget({super.key});
|
||||
|
||||
@override
|
||||
State<FloatBarWidget> createState() => _FloatBarWidgetState();
|
||||
}
|
||||
|
||||
class _FloatBarWidgetState extends State<FloatBarWidget> {
|
||||
late final FloatBarBloc _bloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
debugPrint('🔥 [FloatBarWidget] initState - 开始创建 Bloc');
|
||||
// 内部创建Bloc并启动服务(不需要FloatBarManager,因为我们使用Stack方式)
|
||||
_bloc = FloatBarBloc(RobotStatusService());
|
||||
_bloc.startService();
|
||||
debugPrint('✅ [FloatBarWidget] Bloc 已创建并启动服务');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
debugPrint('🔥 [FloatBarWidget] dispose - 关闭 Bloc');
|
||||
_bloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
debugPrint('🔥 [FloatBarWidget] build - 渲染悬浮条');
|
||||
return BlocProvider.value(value: _bloc, child: const _FloatBarContent());
|
||||
}
|
||||
}
|
||||
|
||||
/// 悬浮条内容组件
|
||||
class _FloatBarContent extends StatelessWidget {
|
||||
const _FloatBarContent();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<FloatBarBloc, FloatBarState>(
|
||||
builder: (context, state) {
|
||||
final isExpanded = state is FloatBarExpandedState;
|
||||
final status = state is FloatBarCollapsedState
|
||||
? state.status
|
||||
: (state as FloatBarExpandedState).status;
|
||||
|
||||
debugPrint(
|
||||
'🔥 [FloatBarContent] build - isExpanded: $isExpanded, status: ${status.taskName}',
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), // 底部留出 Tab 栏空间
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
debugPrint('🔥 [FloatBarContent] 点击悬浮条');
|
||||
context.read<FloatBarBloc>().add(ToggleExpandEvent());
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
height: isExpanded ? 180 : 56,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Color(0xFFE8F5E9), // 浅绿色
|
||||
Color(0xFFFFFFFF), // 白色
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.green.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.12),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
spreadRadius: 2,
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
spreadRadius: 1,
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
spreadRadius: -2,
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: isExpanded
|
||||
? _buildExpandedContent(status)
|
||||
: _buildCollapsedContent(status),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 折叠态内容
|
||||
Widget _buildCollapsedContent(RobotStatusModel status) {
|
||||
return Row(
|
||||
children: [
|
||||
// 状态指示灯
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: _parseColor(status.statusColor),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 任务名称
|
||||
Expanded(
|
||||
child: Text(
|
||||
status.taskName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 电量
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
status.battery > 20 ? Icons.battery_full : Icons.battery_alert,
|
||||
size: 18,
|
||||
color: status.battery > 20 ? Colors.grey : Colors.red,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${status.battery}%',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: status.battery > 20 ? Colors.black87 : Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 展开箭头
|
||||
const Icon(Icons.keyboard_arrow_up, size: 20, color: Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
// 关闭按钮
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
debugPrint('🔥 [FloatBarContent] 点击关闭按钮');
|
||||
// 🔥 使用静态方法关闭悬浮条
|
||||
FloatBarSettingService.setEnabled(false);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.close, size: 16, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 展开态内容
|
||||
Widget _buildExpandedContent(RobotStatusModel status) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 顶部:状态+任务名+折叠按钮
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: _parseColor(status.statusColor),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _parseColor(status.statusColor).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
status.statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _parseColor(status.statusColor),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
status.taskName,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.keyboard_arrow_down, size: 20, color: Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
// 关闭按钮
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
debugPrint('🔥 [FloatBarContent] 点击关闭按钮(展开态)');
|
||||
// 🔥 使用静态方法关闭悬浮条
|
||||
FloatBarSettingService.setEnabled(false);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.close, size: 16, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 中间:详细信息网格
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildInfoItem(
|
||||
status.battery > 20 ? Icons.battery_full : Icons.battery_alert,
|
||||
'电量',
|
||||
'${status.battery}%',
|
||||
status.battery > 20 ? Colors.black87 : Colors.red,
|
||||
),
|
||||
_buildInfoItem(
|
||||
Icons.signal_cellular_alt,
|
||||
'信号',
|
||||
'${status.signal}%',
|
||||
Colors.black87,
|
||||
),
|
||||
_buildInfoItem(
|
||||
Icons.speed,
|
||||
'速度',
|
||||
'${status.speed.toStringAsFixed(1)}m/s',
|
||||
Colors.black87,
|
||||
),
|
||||
_buildInfoItem(
|
||||
Icons.thermostat,
|
||||
'温度',
|
||||
'${status.temperature}°C',
|
||||
Colors.black87,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 底部:位置+时间
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on, size: 14, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
status.location,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Icon(Icons.timer, size: 14, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
status.runTime,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 信息项组件
|
||||
Widget _buildInfoItem(
|
||||
IconData icon,
|
||||
String label,
|
||||
String value,
|
||||
Color valueColor,
|
||||
) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Colors.grey),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: Colors.grey)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 解析颜色字符串
|
||||
Color _parseColor(String colorStr) {
|
||||
try {
|
||||
return Color(int.parse(colorStr.replaceFirst('#', '0xFF')));
|
||||
} catch (_) {
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,15 @@ import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_statu
|
||||
import '../bloc/drone_station_bloc.dart';
|
||||
import '../bloc/drone_station_event.dart';
|
||||
import '../bloc/drone_station_state.dart';
|
||||
import '../bloc/robot_list_bloc.dart'; // 🔥 添加 RobotListBloc 导入
|
||||
import '../bloc/robot_list_event.dart'; // 🔥 添加 RobotListEvent 导入
|
||||
import '../bloc/robot_list_state.dart'; // 🔥 添加 RobotListState 导入
|
||||
import '../widgets/device_item_widget.dart';
|
||||
import '../widgets/drone_station_item_card.dart';
|
||||
import '../widgets/robot_item_card.dart'; // 🔥 添加 RobotItemCard 导入
|
||||
import '../../domain/entities/drone_station_entity.dart'; // 🔥 添加 DroneStationEntity 导入
|
||||
import 'robot_list_page.dart';
|
||||
import 'robot_control_page.dart';
|
||||
import 'drone_station_detail_page.dart';
|
||||
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
|
||||
|
||||
@@ -51,35 +57,36 @@ class DeviceStatusView extends StatelessWidget {
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F7F7),
|
||||
body: SafeArea(
|
||||
child: BlocConsumer<
|
||||
DeviceListBloc.DeviceStatusBloc,
|
||||
DeviceListState.DeviceStatusState
|
||||
>(
|
||||
listener: (context, state) {
|
||||
// 🔥 监听错误状态,显示友好提示
|
||||
if (state is DeviceListState.DeviceStatusError &&
|
||||
state.shouldShowError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildAppBar(context),
|
||||
_buildSearchBar(context),
|
||||
_buildTypeFilterBar(context),
|
||||
Expanded(child: _buildContent(context, state)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
child:
|
||||
BlocConsumer<
|
||||
DeviceListBloc.DeviceStatusBloc,
|
||||
DeviceListState.DeviceStatusState
|
||||
>(
|
||||
listener: (context, state) {
|
||||
// 🔥 监听错误状态,显示友好提示
|
||||
if (state is DeviceListState.DeviceStatusError &&
|
||||
state.shouldShowError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildAppBar(context),
|
||||
_buildSearchBar(context),
|
||||
_buildTypeFilterBar(context),
|
||||
Expanded(child: _buildContent(context, state)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -317,25 +324,11 @@ class DeviceStatusView extends StatelessWidget {
|
||||
children: [
|
||||
_buildStatusCard(state),
|
||||
const SizedBox(height: 20.0),
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: filteredDevices.length,
|
||||
itemBuilder: (context, index) {
|
||||
final device = filteredDevices[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: DeviceItemWidget(
|
||||
device: device,
|
||||
onTap: () {
|
||||
debugPrint('点击设备: ${device.name}');
|
||||
// TODO: 导航到设备详情页
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// 🔥 机器人列表(直接复用 RobotListPage 的布局)
|
||||
if (state.selectedType == '全部') _buildEmbeddedRobotList(context),
|
||||
// 🔥 无人机机场列表(直接复用 _buildDroneStationList 的布局)
|
||||
if (state.selectedType == '全部')
|
||||
_buildDroneStationList(context, embedded: true),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -344,7 +337,87 @@ class DeviceStatusView extends StatelessWidget {
|
||||
return const Center(child: Text('暂无数据'));
|
||||
}
|
||||
|
||||
Widget _buildDroneStationList(BuildContext context) {
|
||||
/// 🔥 嵌入式机器人列表(直接复用 RobotListPage 的布局)
|
||||
Widget _buildEmbeddedRobotList(BuildContext context) {
|
||||
final selectedSite = sl<SiteCubit>().state.selectedSite;
|
||||
final siteId = selectedSite?.id;
|
||||
|
||||
debugPrint('🔍 [EmbeddedRobotList] 开始加载机器人数据, siteId: $siteId');
|
||||
|
||||
return BlocProvider(
|
||||
create: (_) =>
|
||||
sl<RobotListBloc>()..add(RobotListLoadData(siteId: siteId)),
|
||||
child: BlocConsumer<RobotListBloc, RobotListState>(
|
||||
listener: (context, state) {
|
||||
if (state is RobotListError && state.shouldShowError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
debugPrint('🔍 [EmbeddedRobotList] 当前状态: ${state.runtimeType}');
|
||||
|
||||
if (state is RobotListLoading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is RobotListLoaded) {
|
||||
debugPrint(
|
||||
'✅ [EmbeddedRobotList] 加载成功,机器人数量: ${state.robots.length}',
|
||||
);
|
||||
|
||||
// 🔥 直接复用 RobotListPage 的布局结构(不显示标题)
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...state.robots.map((robot) {
|
||||
debugPrint(
|
||||
'📍 [EmbeddedRobotList] 机器人 - name: ${robot.name}, id: ${robot.id}',
|
||||
);
|
||||
return RobotItemCard(
|
||||
name: robot.name,
|
||||
id: robot.id,
|
||||
type: robot.type,
|
||||
status: robot.status,
|
||||
battery: robot.battery,
|
||||
task: robot.task,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
RobotControlPage(robot: robot.toJson()),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (state is RobotListError) {
|
||||
debugPrint('❌ [EmbeddedRobotList] 加载失败: ${state.message}');
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDroneStationList(BuildContext context, {bool embedded = false}) {
|
||||
// 从全局 SiteCubit 获取选中的场站 ID
|
||||
final selectedSite = sl<SiteCubit>().state.selectedSite;
|
||||
|
||||
@@ -414,6 +487,10 @@ class DeviceStatusView extends StatelessWidget {
|
||||
},
|
||||
color: const Color(0xFF165DFF),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: embedded,
|
||||
physics: embedded
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 16),
|
||||
itemCount: stations.length,
|
||||
itemBuilder: (context, index) {
|
||||
@@ -557,4 +634,153 @@ class DeviceStatusView extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔥 根据设备类型导航到不同的详情页
|
||||
void _navigateToDeviceDetail(BuildContext context, dynamic device) {
|
||||
final deviceName = device.name ?? '';
|
||||
final deviceId = device.deviceId ?? '';
|
||||
final deviceType = device.type ?? ''; // 🔥 使用接口返回的设备类型字段
|
||||
|
||||
debugPrint('🔍 [DeviceStatusPage] 设备名称: $deviceName');
|
||||
debugPrint('🔍 [DeviceStatusPage] 设备ID: $deviceId');
|
||||
debugPrint('🔍 [DeviceStatusPage] 设备类型: $deviceType');
|
||||
|
||||
// 🔥 判断是否为机器人(根据设备类型前缀)
|
||||
final robotPrefixes = [
|
||||
'RCHETD-CN',
|
||||
'MC700PLUS-CN',
|
||||
'MC700-CN',
|
||||
'MC700PRO-CN',
|
||||
'RCETD-CN',
|
||||
'MC700AIR-CN',
|
||||
];
|
||||
bool isRobot = robotPrefixes.any((prefix) => deviceType.startsWith(prefix));
|
||||
|
||||
// 🔥 判断是否为无人机机场(根据设备类型包含关键字)
|
||||
final droneStationKeywords = [
|
||||
'DJI Dock3',
|
||||
'DOCK3',
|
||||
'DOCK-2',
|
||||
'采集车',
|
||||
'机场',
|
||||
'无人机机场',
|
||||
];
|
||||
bool isDroneStation = droneStationKeywords.any(
|
||||
(keyword) => deviceType.contains(keyword),
|
||||
);
|
||||
|
||||
// 🔥 兜底逻辑:如果 type 字段是"未知设备"或空,则使用设备名称进行二次判断
|
||||
if (!isRobot &&
|
||||
!isDroneStation &&
|
||||
(deviceType.isEmpty || deviceType == '未知设备' || deviceType == '未知类型')) {
|
||||
debugPrint('⚠️ [DeviceStatusPage] type 字段无效,使用设备名称进行二次判断');
|
||||
|
||||
// 检查每个机器人前缀是否匹配
|
||||
for (final prefix in robotPrefixes) {
|
||||
final nameMatch = deviceName.startsWith(prefix);
|
||||
final idMatch = deviceId.startsWith(prefix);
|
||||
if (nameMatch || idMatch) {
|
||||
debugPrint(
|
||||
'🔍 [DeviceStatusPage] 机器人前缀匹配: "$prefix" -> 名称匹配=$nameMatch, ID匹配=$idMatch',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 根据设备名称前缀判断机器人
|
||||
isRobot = robotPrefixes.any(
|
||||
(prefix) =>
|
||||
deviceName.startsWith(prefix) || deviceId.startsWith(prefix),
|
||||
);
|
||||
|
||||
// 检查每个无人机关键字是否匹配
|
||||
for (final keyword in droneStationKeywords) {
|
||||
if (deviceName.contains(keyword)) {
|
||||
debugPrint('🔍 [DeviceStatusPage] 无人机关键字匹配: "$keyword"');
|
||||
}
|
||||
}
|
||||
|
||||
// 根据设备名称包含关键字判断无人机机场
|
||||
isDroneStation = droneStationKeywords.any(
|
||||
(keyword) => deviceName.contains(keyword),
|
||||
);
|
||||
|
||||
// 🔥 重要:如果同时匹配机器人和无人机机场,优先判断为无人机机场
|
||||
if (isRobot && isDroneStation) {
|
||||
debugPrint('⚠️ [DeviceStatusPage] 同时匹配机器人和无人机机场,优先判断为无人机机场');
|
||||
isRobot = false;
|
||||
isDroneStation = true;
|
||||
}
|
||||
|
||||
debugPrint('🔍 [DeviceStatusPage] 二次判断 - 是否机器人: $isRobot');
|
||||
debugPrint('🔍 [DeviceStatusPage] 二次判断 - 是否无人机机场: $isDroneStation');
|
||||
}
|
||||
|
||||
debugPrint('🔍 [DeviceStatusPage] 最终判断 - 是否机器人: $isRobot');
|
||||
debugPrint('🔍 [DeviceStatusPage] 最终判断 - 是否无人机机场: $isDroneStation');
|
||||
|
||||
if (isRobot) {
|
||||
// 跳转到机器人控制页面
|
||||
debugPrint('🚀 [DeviceStatusPage] 跳转到机器人控制页面');
|
||||
final robotMap = {
|
||||
'name': deviceName,
|
||||
'id': deviceId,
|
||||
'type': deviceType,
|
||||
'status': device.status ?? '在线',
|
||||
'battery': 100.0, // DeviceEntity 没有 battery 字段,使用默认值
|
||||
'task': '待机中', // DeviceEntity 没有 task 字段,使用默认值
|
||||
};
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RobotControlPage(robot: robotMap),
|
||||
),
|
||||
);
|
||||
} else if (isDroneStation) {
|
||||
// 跳转到无人机机场详情页面
|
||||
debugPrint('🚀 [DeviceStatusPage] 跳转到无人机机场详情页面');
|
||||
|
||||
// 🔥 从 DroneStationBloc 中获取已加载的机场列表
|
||||
final droneStationBloc = sl<DroneStationBloc>();
|
||||
DroneStationEntity? matchedStation;
|
||||
|
||||
if (droneStationBloc.state is DroneStationLoaded) {
|
||||
final loadedState = droneStationBloc.state as DroneStationLoaded;
|
||||
// 根据 deviceId 或 deviceName 匹配
|
||||
for (final station in loadedState.stations) {
|
||||
if (station.gatewaySn == deviceId || station.callsign == deviceName) {
|
||||
matchedStation = station;
|
||||
debugPrint(
|
||||
'✅ 匹配到机场 - gatewaySn: ${station.gatewaySn}, deviceSn: ${station.deviceSn}',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedStation != null) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
DroneStationDetailPage(station: matchedStation!),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
debugPrint('⚠️ 未找到匹配的机场,请从无人机机场标签页进入');
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('请从"无人机机场"标签页查看详情')));
|
||||
}
|
||||
} else {
|
||||
// 其他设备类型,显示提示
|
||||
debugPrint('⚠️ [DeviceStatusPage] 未知设备类型: $deviceType');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('该设备类型暂不支持查看详情: $deviceType'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import '../../domain/entities/drone_station_entity.dart';
|
||||
import '../bloc/drone_station_bloc.dart';
|
||||
import '../bloc/drone_station_event.dart';
|
||||
import '../bloc/drone_station_state.dart';
|
||||
import '../widgets/drone_station_osd_card.dart'; // 🔥 添加机场 OSD 卡片
|
||||
import '../widgets/drone_osd_card.dart'; // 🔥 添加无人机 OSD 卡片
|
||||
import 'drone_video_control_page.dart';
|
||||
import 'drone_mission_control_page.dart';
|
||||
import 'drone_monitor_page.dart';
|
||||
@@ -27,8 +29,6 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
UAVDetailEntity? _detail;
|
||||
String? _droneSn;
|
||||
|
||||
|
||||
|
||||
// 无人机状态轮询计时器
|
||||
Timer? _droneStatusPollingTimer;
|
||||
|
||||
@@ -44,59 +44,59 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
);
|
||||
|
||||
// 启动无人机状态轮询(每5秒刷新一次)
|
||||
_startDroneStatusPolling();
|
||||
// 🔥 已禁用自动轮询,改为手动下拉刷新
|
||||
// _startDroneStatusPolling();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.close();
|
||||
_droneStatusPollingTimer?.cancel(); // 停止轮询
|
||||
// 🔥 已禁用自动轮询,无需停止
|
||||
// _droneStatusPollingTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 启动无人机状态轮询
|
||||
/// 🔥 已禁用自动轮询功能
|
||||
void _startDroneStatusPolling() {
|
||||
_scheduleDroneStatusPoll();
|
||||
// _scheduleDroneStatusPoll();
|
||||
}
|
||||
|
||||
/// 根据无人机状态动态调整轮询周期
|
||||
/// 🔥 已禁用自动轮询功能
|
||||
void _scheduleDroneStatusPoll() {
|
||||
if (!mounted) return;
|
||||
|
||||
// 检查当前无人机状态
|
||||
Duration interval = const Duration(seconds: 60); // 默认60秒
|
||||
final currentState = _bloc.state;
|
||||
if (currentState is UAVDetailLoaded) {
|
||||
if (currentState.detail.droneOnlineStatus == 1) {
|
||||
// 无人机在线时,每30秒轮询一次
|
||||
interval = const Duration(seconds: 30);
|
||||
} else {
|
||||
// 无人机离线时,每60秒轮询一次(降低频率)
|
||||
interval = const Duration(seconds: 60);
|
||||
}
|
||||
}
|
||||
|
||||
_droneStatusPollingTimer?.cancel();
|
||||
_droneStatusPollingTimer = Timer(interval, () {
|
||||
if (!mounted) return;
|
||||
|
||||
debugPrint('🔄 定时刷新无人机状态...');
|
||||
_bloc.add(
|
||||
UAVDetailLoad(
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
deviceSn: widget.station.deviceSn,
|
||||
),
|
||||
);
|
||||
|
||||
// 重新调度下一次轮询(动态周期)
|
||||
_scheduleDroneStatusPoll();
|
||||
});
|
||||
// if (!mounted) return;
|
||||
//
|
||||
// // 检查当前无人机状态
|
||||
// Duration interval = const Duration(seconds: 60); // 默认60秒
|
||||
// final currentState = _bloc.state;
|
||||
// if (currentState is UAVDetailLoaded) {
|
||||
// if (currentState.detail.droneOnlineStatus == 1) {
|
||||
// // 无人机在线时,每30秒轮询一次
|
||||
// interval = const Duration(seconds: 30);
|
||||
// } else {
|
||||
// // 无人机离线时,每60秒轮询一次(降低频率)
|
||||
// interval = const Duration(seconds: 60);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// _droneStatusPollingTimer?.cancel();
|
||||
// _droneStatusPollingTimer = Timer(interval, () {
|
||||
// if (!mounted) return;
|
||||
//
|
||||
// debugPrint('🔄 定时刷新无人机状态...');
|
||||
// _bloc.add(
|
||||
// UAVDetailLoad(
|
||||
// gatewaySn: widget.station.gatewaySn,
|
||||
// deviceSn: widget.station.deviceSn,
|
||||
// ),
|
||||
// );
|
||||
//
|
||||
// // 重新调度下一次轮询(动态周期)
|
||||
// _scheduleDroneStatusPoll();
|
||||
// });
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
@@ -182,22 +182,35 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
_detail = detail; // 保存详情数据供其他方法使用
|
||||
_droneSn = detail.deviceSn; // 保存无人机序列号
|
||||
return RefreshIndicator(
|
||||
onRefresh: _handleRefresh,
|
||||
color: const Color(0xFF165DFF),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildAirportStatusCard(detail),
|
||||
const SizedBox(height: 12),
|
||||
_buildMonitorCard(),
|
||||
const SizedBox(height: 12),
|
||||
_buildDroneStatusCard(detail),
|
||||
const SizedBox(height: 12),
|
||||
_buildQuickActions(),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
onRefresh: _handleRefresh,
|
||||
color: const Color(0xFF165DFF),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildAirportStatusCard(detail),
|
||||
const SizedBox(height: 12),
|
||||
// 🔥 添加机场 OSD 实时数据卡片
|
||||
DroneStationOsdCard(
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
isOnline: detail.isOnline,
|
||||
),
|
||||
);
|
||||
const SizedBox(height: 12),
|
||||
// 🔥 添加无人机 OSD 实时数据卡片(无人机在线时显示)
|
||||
DroneOsdCard(
|
||||
deviceSn: widget.station.deviceSn,
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
isDroneOnline: detail.isDroneOnline,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildMonitorCard(),
|
||||
const SizedBox(height: 12),
|
||||
_buildDroneStatusCard(detail),
|
||||
const SizedBox(height: 12),
|
||||
_buildQuickActions(),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 处理下拉刷新
|
||||
@@ -210,7 +223,8 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
),
|
||||
);
|
||||
// 重置轮询计时器,使用新的状态
|
||||
_scheduleDroneStatusPoll();
|
||||
// 🔥 已禁用自动轮询,无需重置
|
||||
// _scheduleDroneStatusPoll();
|
||||
}
|
||||
|
||||
Widget _buildMonitorCard() {
|
||||
@@ -348,7 +362,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
'风速',
|
||||
detail.windSpeed != null ? '${detail.windSpeed} m/s' : '未知',
|
||||
),
|
||||
_buildInfoRow('降雨量', detail.rainfall ?? '未知'),
|
||||
_buildInfoRow('降雨量', _formatRainfall(detail.rainfall)),
|
||||
_buildInfoRow('网络状态', detail.networkState?.toString() ?? '未知'),
|
||||
_buildPositionStateRow('位置状态', detail.positionState),
|
||||
],
|
||||
@@ -356,6 +370,37 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPositionStateRow(String label, PositionState? positionState) {
|
||||
String value = '未知';
|
||||
if (positionState != null) {
|
||||
final fixedStatus = positionState.isFixed == 'fixing_successful'
|
||||
? '已固定'
|
||||
: '未固定';
|
||||
value =
|
||||
'GPS:${positionState.gpsNumber} RTX:${positionState.rtkNumber} 固定:$fixedStatus';
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)),
|
||||
),
|
||||
const Spacer(),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF1D2129),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDroneStatusCard(UAVDetailEntity detail) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
@@ -644,8 +689,6 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Widget _buildQuickActionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
@@ -710,36 +753,6 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPositionStateRow(String label, PositionState? positionState) {
|
||||
String value = '未知';
|
||||
if (positionState != null) {
|
||||
value =
|
||||
'GPS:${positionState.gpsNumber} RTX:${positionState.rtkNumber} 固定:${positionState.isFixed}';
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)),
|
||||
),
|
||||
const Spacer(),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF1D2129),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Widget _buildCameraListRow(String label, List<CameraInfo> cameras) {
|
||||
String value = cameras
|
||||
.map((c) => '${c.cameraIndex}:${c.cameraPosition}')
|
||||
@@ -766,4 +779,31 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 格式化降雨量显示
|
||||
String _formatRainfall(String? rainfall) {
|
||||
if (rainfall == null || rainfall.isEmpty) {
|
||||
return '未知';
|
||||
}
|
||||
|
||||
// 特殊值映射
|
||||
switch (rainfall.toLowerCase()) {
|
||||
case 'no_rain':
|
||||
return '无降雨';
|
||||
case 'light_rain':
|
||||
return '小雨';
|
||||
case 'moderate_rain':
|
||||
return '中雨';
|
||||
case 'heavy_rain':
|
||||
return '大雨';
|
||||
default:
|
||||
// 如果是数字,直接显示
|
||||
try {
|
||||
final value = double.parse(rainfall);
|
||||
return '$value mm';
|
||||
} catch (e) {
|
||||
return rainfall;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,6 +472,7 @@ class _RobotListViewState extends State<RobotListView> {
|
||||
return filteredRobots.map((robot) => RobotItemCard(
|
||||
name: robot.name,
|
||||
id: robot.id,
|
||||
alias: robot.alias, // 传递别名
|
||||
type: robot.type,
|
||||
status: robot.status,
|
||||
battery: robot.battery,
|
||||
@@ -503,6 +504,7 @@ class _RobotListViewState extends State<RobotListView> {
|
||||
final robotMap = {
|
||||
'name': robot.name,
|
||||
'id': robot.id,
|
||||
'alias': robot.alias, // 传递别名
|
||||
'type': robot.type,
|
||||
'status': robot.status,
|
||||
'battery': robot.battery,
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart';
|
||||
import '../../../../../core/network/mqtt/domain/entities/drone_osd_entity.dart';
|
||||
|
||||
/// 无人机 OSD 实时数据卡片(网格布局展示所有数据)
|
||||
class DroneOsdCard extends StatefulWidget {
|
||||
final String deviceSn;
|
||||
final String gatewaySn;
|
||||
final bool isDroneOnline;
|
||||
|
||||
const DroneOsdCard({
|
||||
super.key,
|
||||
required this.deviceSn,
|
||||
required this.gatewaySn,
|
||||
required this.isDroneOnline,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DroneOsdCard> createState() => _DroneOsdCardState();
|
||||
}
|
||||
|
||||
class _DroneOsdCardState extends State<DroneOsdCard> {
|
||||
late DroneOsdDataSource _dataSource;
|
||||
StreamSubscription<DroneOsdEntity>? _subscription;
|
||||
DroneOsdEntity? _currentOsd;
|
||||
|
||||
// OSD 数据字段列表
|
||||
List<Map<String, dynamic>> _osdFields = [];
|
||||
|
||||
// 缓存上次的有效值,避免闪烁
|
||||
Map<String, String> _cachedValues = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_dataSource = sl<DroneOsdDataSource>();
|
||||
_startListening();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DroneOsdCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.deviceSn != widget.deviceSn ||
|
||||
oldWidget.gatewaySn != widget.gatewaySn ||
|
||||
oldWidget.isDroneOnline != widget.isDroneOnline) {
|
||||
_stopListening();
|
||||
_startListening();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopListening();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startListening() {
|
||||
if (!widget.isDroneOnline) return;
|
||||
if (widget.deviceSn.isEmpty) return;
|
||||
|
||||
debugPrint('🔊 [DroneOsdCard] 开始监听无人机 OSD: ${widget.deviceSn}');
|
||||
|
||||
_dataSource.startListening(
|
||||
deviceSn: widget.deviceSn,
|
||||
gatewaySn: widget.gatewaySn,
|
||||
);
|
||||
|
||||
_subscription = _dataSource.droneOsdStream.listen((osd) {
|
||||
if (!mounted) return;
|
||||
|
||||
debugPrint('📥 [DroneOsdCard] 收到无人机 OSD 数据');
|
||||
|
||||
setState(() {
|
||||
_currentOsd = osd;
|
||||
_parseOsdFields(osd);
|
||||
});
|
||||
|
||||
debugPrint('✅ [DroneOsdCard] UI 已更新,字段数: ${_osdFields.length}');
|
||||
});
|
||||
}
|
||||
|
||||
void _stopListening() {
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
_dataSource.stopListening();
|
||||
debugPrint('⏹️ [DroneOsdCard] 停止监听无人机 OSD');
|
||||
}
|
||||
|
||||
/// 解析无人机 OSD 数据为可展示的字段列表
|
||||
void _parseOsdFields(DroneOsdEntity osd) {
|
||||
final data = osd.rawData;
|
||||
|
||||
// 解析嵌套的 JSON 结构
|
||||
final droneData = data['data'] is Map
|
||||
? (data['data'] as Map)['drone']
|
||||
: null;
|
||||
if (droneData == null || droneData is! Map) {
|
||||
debugPrint('⚠️ [DroneOsdCard] 无法解析 drone 数据');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('📊 [DroneOsdCard] drone 数据键: ${droneData.keys.toList()}');
|
||||
|
||||
// ========== 1. 基础飞行信息 ==========
|
||||
// 无人机高度(height)
|
||||
double? height = (droneData['height'] as num?)?.toDouble();
|
||||
|
||||
// 飞行速度(ground_speed)
|
||||
double? groundSpeed = (droneData['ground_speed'] as num?)?.toDouble();
|
||||
|
||||
// 飞行距离(flight_distance)
|
||||
double? flightDistance = (droneData['flight_distance'] as num?)?.toDouble();
|
||||
|
||||
// 飞行时间(flight_time)
|
||||
int? flightTime = droneData['flight_time'] as int?;
|
||||
|
||||
// ========== 2. 电池信息 ==========
|
||||
// 电量百分比(battery_percent)
|
||||
int? batteryPercent = droneData['battery_percent'] as int?;
|
||||
|
||||
// 电池电压(battery_voltage)
|
||||
double? batteryVoltage = (droneData['battery_voltage'] as num?)?.toDouble();
|
||||
|
||||
// 电池电流(battery_current)
|
||||
double? batteryCurrent = (droneData['battery_current'] as num?)?.toDouble();
|
||||
|
||||
// 电池温度(battery_temperature)
|
||||
double? batteryTemp = (droneData['battery_temperature'] as num?)
|
||||
?.toDouble();
|
||||
|
||||
// ========== 3. 位置与姿态 ==========
|
||||
// 纬度(latitude)
|
||||
double? latitude = (droneData['latitude'] as num?)?.toDouble();
|
||||
|
||||
// 经度(longitude)
|
||||
double? longitude = (droneData['longitude'] as num?)?.toDouble();
|
||||
|
||||
// 航向角(heading)
|
||||
double? heading = (droneData['heading'] as num?)?.toDouble();
|
||||
|
||||
// 俯仰角(pitch)
|
||||
double? pitch = (droneData['pitch'] as num?)?.toDouble();
|
||||
|
||||
// 横滚角(roll)
|
||||
double? roll = (droneData['roll'] as num?)?.toDouble();
|
||||
|
||||
// ========== 4. GPS 状态 ==========
|
||||
// GPS卫星数(gps_satellites)
|
||||
int? gpsSatellites = droneData['gps_satellites'] as int?;
|
||||
|
||||
// GPS信号质量(gps_quality)
|
||||
int? gpsQuality = droneData['gps_quality'] as int?;
|
||||
|
||||
// ========== 5. 遥控信号 ==========
|
||||
// 遥控信号强度(rc_signal_strength)
|
||||
int? rcSignal = droneData['rc_signal_strength'] as int?;
|
||||
|
||||
// 图传信号强度(video_signal_strength)
|
||||
int? videoSignal = droneData['video_signal_strength'] as int?;
|
||||
|
||||
// ========== 6. 飞行模式 ==========
|
||||
// 飞行模式(flight_mode)
|
||||
String? flightMode = droneData['flight_mode'] as String?;
|
||||
|
||||
// ========== 7. 电机状态 ==========
|
||||
// 电机状态(motor_status)
|
||||
int? motorStatus = droneData['motor_status'] as int?;
|
||||
|
||||
// ========== 8. 任务状态 ==========
|
||||
// 任务进度(mission_progress)
|
||||
int? missionProgress = droneData['mission_progress'] as int?;
|
||||
|
||||
// 航点数量(waypoint_count)
|
||||
int? waypointCount = droneData['waypoint_count'] as int?;
|
||||
|
||||
// 当前航点(current_waypoint)
|
||||
int? currentWaypoint = droneData['current_waypoint'] as int?;
|
||||
|
||||
// 剩余航点(remaining_waypoints)
|
||||
int? remainingWaypoints = droneData['remaining_waypoints'] as int?;
|
||||
|
||||
debugPrint('✅ [DroneOsdCard] 解析结果:');
|
||||
debugPrint(' 高度: $height m');
|
||||
debugPrint(' 速度: $groundSpeed m/s');
|
||||
debugPrint(' 距离: $flightDistance m');
|
||||
debugPrint(' 时间: $flightTime s');
|
||||
debugPrint(' 电量: $batteryPercent%');
|
||||
debugPrint(' 电压: $batteryVoltage V');
|
||||
debugPrint(' GPS: $gpsSatellites 颗卫星');
|
||||
debugPrint(' 航向: $heading°');
|
||||
debugPrint(' 飞行模式: $flightMode');
|
||||
|
||||
// 构建展示字段列表(精选重要字段)
|
||||
_osdFields = [
|
||||
{
|
||||
'icon': Icons.height,
|
||||
'label': '飞行高度',
|
||||
'key': 'height',
|
||||
'newValue': height != null ? '${height.toStringAsFixed(1)}m' : null,
|
||||
'color': const Color(0xFF165DFF),
|
||||
},
|
||||
{
|
||||
'icon': Icons.speed,
|
||||
'label': '飞行速度',
|
||||
'key': 'speed',
|
||||
'newValue': groundSpeed != null
|
||||
? '${groundSpeed.toStringAsFixed(1)}m/s'
|
||||
: null,
|
||||
'color': const Color(0xFF722ED1),
|
||||
},
|
||||
{
|
||||
'icon': Icons.route,
|
||||
'label': '飞行距离',
|
||||
'key': 'distance',
|
||||
'newValue': flightDistance != null
|
||||
? '${flightDistance.toStringAsFixed(0)}m'
|
||||
: null,
|
||||
'color': const Color(0xFF00B42A),
|
||||
},
|
||||
{
|
||||
'icon': Icons.timer,
|
||||
'label': '飞行时间',
|
||||
'key': 'time',
|
||||
'newValue': flightTime != null
|
||||
? '${(flightTime / 60).toStringAsFixed(1)}min'
|
||||
: null,
|
||||
'color': const Color(0xFF86909C),
|
||||
},
|
||||
{
|
||||
'icon': Icons.battery_full_rounded,
|
||||
'label': '电量',
|
||||
'key': 'battery',
|
||||
'newValue': batteryPercent != null ? '$batteryPercent%' : null,
|
||||
'color': _getBatteryColor(batteryPercent),
|
||||
},
|
||||
{
|
||||
'icon': Icons.bolt,
|
||||
'label': '电池电压',
|
||||
'key': 'voltage',
|
||||
'newValue': batteryVoltage != null
|
||||
? '${batteryVoltage.toStringAsFixed(1)}V'
|
||||
: null,
|
||||
'color': const Color(0xFF00B42A),
|
||||
},
|
||||
{
|
||||
'icon': Icons.thermostat,
|
||||
'label': '电池温度',
|
||||
'key': 'batteryTemp',
|
||||
'newValue': batteryTemp != null
|
||||
? '${batteryTemp.toStringAsFixed(0)}°C'
|
||||
: null,
|
||||
'color': batteryTemp != null && batteryTemp > 50
|
||||
? const Color(0xFFF53F3F)
|
||||
: const Color(0xFF165DFF),
|
||||
},
|
||||
{
|
||||
'icon': Icons.navigation,
|
||||
'label': '航向角',
|
||||
'key': 'heading',
|
||||
'newValue': heading != null ? '${heading.toStringAsFixed(0)}°' : null,
|
||||
'color': const Color(0xFF165DFF),
|
||||
},
|
||||
{
|
||||
'icon': Icons.rotate_90_degrees_cw,
|
||||
'label': '俯仰角',
|
||||
'key': 'pitch',
|
||||
'newValue': pitch != null ? '${pitch.toStringAsFixed(1)}°' : null,
|
||||
'color': const Color(0xFF722ED1),
|
||||
},
|
||||
{
|
||||
'icon': Icons.rotate_right,
|
||||
'label': '横滚角',
|
||||
'key': 'roll',
|
||||
'newValue': roll != null ? '${roll.toStringAsFixed(1)}°' : null,
|
||||
'color': const Color(0xFF722ED1),
|
||||
},
|
||||
{
|
||||
'icon': Icons.satellite,
|
||||
'label': 'GPS卫星',
|
||||
'key': 'gps',
|
||||
'newValue': gpsSatellites != null ? '$gpsSatellites颗' : null,
|
||||
'color': gpsSatellites != null && gpsSatellites >= 6
|
||||
? const Color(0xFF00B42A)
|
||||
: const Color(0xFFFF7D00),
|
||||
},
|
||||
{
|
||||
'icon': Icons.signal_cellular_alt,
|
||||
'label': '遥控信号',
|
||||
'key': 'rcSignal',
|
||||
'newValue': rcSignal != null ? '$rcSignal%' : null,
|
||||
'color': rcSignal != null && rcSignal > 80
|
||||
? const Color(0xFF00B42A)
|
||||
: rcSignal != null && rcSignal > 50
|
||||
? const Color(0xFFFF7D00)
|
||||
: const Color(0xFFF53F3F),
|
||||
},
|
||||
{
|
||||
'icon': Icons.video_label,
|
||||
'label': '图传信号',
|
||||
'key': 'videoSignal',
|
||||
'newValue': videoSignal != null ? '$videoSignal%' : null,
|
||||
'color': videoSignal != null && videoSignal > 80
|
||||
? const Color(0xFF00B42A)
|
||||
: videoSignal != null && videoSignal > 50
|
||||
? const Color(0xFFFF7D00)
|
||||
: const Color(0xFFF53F3F),
|
||||
},
|
||||
{
|
||||
'icon': Icons.flight,
|
||||
'label': '飞行模式',
|
||||
'key': 'flightMode',
|
||||
'newValue': flightMode,
|
||||
'color': const Color(0xFF165DFF),
|
||||
},
|
||||
{
|
||||
'icon': Icons.radio_button_checked,
|
||||
'label': '任务进度',
|
||||
'key': 'mission',
|
||||
'newValue': missionProgress != null ? '$missionProgress%' : null,
|
||||
'color': const Color(0xFF00B42A),
|
||||
},
|
||||
{
|
||||
'icon': Icons.map,
|
||||
'label': '航点',
|
||||
'key': 'waypoint',
|
||||
'newValue': (currentWaypoint != null && waypointCount != null)
|
||||
? '$currentWaypoint/$waypointCount'
|
||||
: null,
|
||||
'color': const Color(0xFF722ED1),
|
||||
},
|
||||
];
|
||||
|
||||
// 应用缓存逻辑:有新值则更新缓存,否则使用旧值
|
||||
for (var field in _osdFields) {
|
||||
final key = field['key'] as String;
|
||||
final newValue = field['newValue'] as String?;
|
||||
|
||||
if (newValue != null && newValue != '未知' && newValue.isNotEmpty) {
|
||||
_cachedValues[key] = newValue;
|
||||
field['value'] = newValue;
|
||||
} else {
|
||||
field['value'] = _cachedValues[key] ?? '未知';
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('✅ [DroneOsdCard] 共解析 ${_osdFields.length} 个字段');
|
||||
}
|
||||
|
||||
Color _getBatteryColor(dynamic battery) {
|
||||
if (battery == null) return const Color(0xFF86909C);
|
||||
|
||||
final value = battery is num ? battery.toDouble() : 0.0;
|
||||
if (value > 50) return const Color(0xFF00B42A);
|
||||
if (value > 20) return const Color(0xFFFF7D00);
|
||||
return const Color(0xFFF53F3F);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.isDroneOnline) {
|
||||
return _buildOfflineCard();
|
||||
}
|
||||
|
||||
if (_osdFields.isEmpty) {
|
||||
return _buildLoadingCard();
|
||||
}
|
||||
|
||||
return _buildOsdGridCard();
|
||||
}
|
||||
|
||||
/// 离线状态卡片
|
||||
Widget _buildOfflineCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.flight_land, size: 48, color: const Color(0xFF86909C)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text(
|
||||
'无人机离线',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'无人机实时数据不可用',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFF86909C)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 加载中卡片
|
||||
Widget _buildLoadingCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// OSD 网格布局卡片(核心功能)
|
||||
Widget _buildOsdGridCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.flight, size: 20, color: const Color(0xFF165DFF)),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'无人机实时信息',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 网格布局展示所有字段
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 0.85,
|
||||
),
|
||||
itemCount: _osdFields.length,
|
||||
itemBuilder: (context, index) {
|
||||
final field = _osdFields[index];
|
||||
return _buildOsdGridItem(field);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// OSD 网格项
|
||||
Widget _buildOsdGridItem(Map<String, dynamic> field) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: (field['color'] as Color).withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: (field['color'] as Color).withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 图标
|
||||
Icon(
|
||||
field['icon'] as IconData,
|
||||
color: field['color'] as Color,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 标签
|
||||
Text(
|
||||
field['label'] as String,
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF86909C)),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// 数值
|
||||
Text(
|
||||
field['value'] as String,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: field['color'] as Color,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) ...[
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart';
|
||||
import '../../../../../core/network/mqtt/domain/entities/drone_osd_entity.dart';
|
||||
|
||||
/// 无人机机场 OSD 实时数据卡片(网格布局展示所有数据)
|
||||
class DroneStationOsdCard extends StatefulWidget {
|
||||
final String gatewaySn;
|
||||
final bool isOnline;
|
||||
|
||||
const DroneStationOsdCard({
|
||||
super.key,
|
||||
required this.gatewaySn,
|
||||
required this.isOnline,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DroneStationOsdCard> createState() => _DroneStationOsdCardState();
|
||||
}
|
||||
|
||||
class _DroneStationOsdCardState extends State<DroneStationOsdCard> {
|
||||
late DroneOsdDataSource _dataSource;
|
||||
StreamSubscription<DroneOsdEntity>? _subscription;
|
||||
DroneOsdEntity? _currentOsd;
|
||||
|
||||
// OSD 数据字段列表
|
||||
List<Map<String, dynamic>> _osdFields = [];
|
||||
|
||||
// 🔥 缓存上次的有效值,避免闪烁
|
||||
Map<String, String> _cachedValues = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_dataSource = sl<DroneOsdDataSource>();
|
||||
_startListening();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DroneStationOsdCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.gatewaySn != widget.gatewaySn ||
|
||||
oldWidget.isOnline != widget.isOnline) {
|
||||
_stopListening();
|
||||
_startListening();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopListening();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startListening() {
|
||||
if (!widget.isOnline) return;
|
||||
|
||||
//debugPrint('🔊 [DroneStationOsdCard] 开始监听机场 OSD: ${widget.gatewaySn}');
|
||||
|
||||
_dataSource.startListening(
|
||||
deviceSn: '', // 机场不需要 deviceSn
|
||||
gatewaySn: widget.gatewaySn,
|
||||
);
|
||||
|
||||
_subscription = _dataSource.stationOsdStream.listen((osd) {
|
||||
if (!mounted) return;
|
||||
|
||||
//debugPrint('📥 [DroneStationOsdCard] 收到机场 OSD 原始数据');
|
||||
|
||||
setState(() {
|
||||
_currentOsd = osd;
|
||||
_parseOsdFields(osd);
|
||||
});
|
||||
|
||||
// debugPrint('✅ [DroneStationOsdCard] UI 已更新,字段数: ${_osdFields.length}');
|
||||
});
|
||||
}
|
||||
|
||||
void _stopListening() {
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
_dataSource.stopListening();
|
||||
debugPrint('⏹️ [DroneStationOsdCard] 停止监听机场 OSD');
|
||||
}
|
||||
|
||||
/// 解析 OSD 数据为可展示的字段列表
|
||||
void _parseOsdFields(DroneOsdEntity osd) {
|
||||
final data = osd.rawData;
|
||||
|
||||
// 解析嵌套的 JSON 结构
|
||||
final hostData = data['data'] is Map ? (data['data'] as Map)['host'] : null;
|
||||
if (hostData == null || hostData is! Map) {
|
||||
debugPrint('⚠️ [DroneStationOsdCard] 无法解析 host 数据');
|
||||
return;
|
||||
}
|
||||
|
||||
// debugPrint('📊 [DroneStationOsdCard] 开始解析 OSD 数据...');
|
||||
//debugPrint('📊 [DroneStationOsdCard] host 数据键: ${hostData.keys.toList()}');
|
||||
|
||||
// ========== 1. 电池相关 ==========
|
||||
// 提取无人机电量(从 drone_battery_maintenance_info.batteries[0].capacity_percent)
|
||||
double? batteryPercent;
|
||||
final batteryInfo = hostData['drone_battery_maintenance_info'];
|
||||
if (batteryInfo is Map && batteryInfo['batteries'] is List) {
|
||||
final batteries = batteryInfo['batteries'] as List;
|
||||
if (batteries.isNotEmpty && batteries[0] is Map) {
|
||||
batteryPercent = (batteries[0]['capacity_percent'] as num?)?.toDouble();
|
||||
}
|
||||
}
|
||||
|
||||
// 提取备用电池温度(从 backup_battery.temperature)
|
||||
double? backupBatteryTemp;
|
||||
final backupBattery = hostData['backup_battery'];
|
||||
if (backupBattery is Map) {
|
||||
backupBatteryTemp = (backupBattery['temperature'] as num?)?.toDouble();
|
||||
}
|
||||
|
||||
// 提取备用电池电压(从 backup_battery.voltage)
|
||||
int? backupBatteryVoltage;
|
||||
if (backupBattery is Map) {
|
||||
backupBatteryVoltage = backupBattery['voltage'] as int?;
|
||||
}
|
||||
|
||||
// 提取备用电池开关状态(从 backup_battery.switch)
|
||||
int? backupBatterySwitch;
|
||||
if (backupBattery is Map) {
|
||||
backupBatterySwitch = backupBattery['switch'] as int?;
|
||||
}
|
||||
|
||||
// ========== 2. 电源相关 ==========
|
||||
// 提取交流输入功率(acdc_power_input)
|
||||
double? acdcPower = (hostData['acdc_power_input'] as num?)?.toDouble();
|
||||
|
||||
// 提取供电电压(electric_supply_voltage)
|
||||
int? supplyVoltage = hostData['electric_supply_voltage'] as int?;
|
||||
|
||||
// 提取 PoE 链路状态(poe_link_status)
|
||||
int? poeLinkStatus = hostData['poe_link_status'] as int?;
|
||||
|
||||
// 提取 PoE 输出功率(poe_power_output)
|
||||
double? poePowerOutput = (hostData['poe_power_output'] as num?)?.toDouble();
|
||||
|
||||
// ========== 3. 部署与维护 ==========
|
||||
// 提取部署模式(deployment_mode)
|
||||
int? deploymentMode = hostData['deployment_mode'] as int?;
|
||||
|
||||
// 提取作业编号(job_number)
|
||||
int? jobNumber = hostData['job_number'] as int?;
|
||||
|
||||
// 提取云台 holder 状态(gimbal_holder_state)
|
||||
int? gimbalHolderState = hostData['gimbal_holder_state'] as int?;
|
||||
|
||||
// 提取维护状态(maintain_status)
|
||||
String maintainStatus = '未知';
|
||||
final maintainStatusData = hostData['maintain_status'];
|
||||
if (maintainStatusData is Map && maintainStatusData['maintain_status_array'] is List) {
|
||||
final statusArray = maintainStatusData['maintain_status_array'] as List;
|
||||
if (statusArray.isNotEmpty && statusArray[0] is Map) {
|
||||
final firstStatus = statusArray[0] as Map;
|
||||
final state = firstStatus['state'];
|
||||
final maintainType = firstStatus['last_maintain_type'];
|
||||
maintainStatus = '状态:$state 类型:$maintainType';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 4. 位置与坐标 ==========
|
||||
// 提取相对备降点信息(relative_alternate_land_point)
|
||||
String landPointInfo = '未知';
|
||||
final landPointData = hostData['relative_alternate_land_point'];
|
||||
if (landPointData is Map) {
|
||||
final lat = landPointData['latitude'];
|
||||
final lon = landPointData['longitude'];
|
||||
final safeHeight = landPointData['safe_land_height'];
|
||||
final status = landPointData['status'];
|
||||
landPointInfo = 'LAT:${lat?.toStringAsFixed(4)} LON:${lon?.toStringAsFixed(4)} H:${safeHeight}m S:$status';
|
||||
}
|
||||
|
||||
// 提取自收敛坐标(self_converge_coordinate)
|
||||
String convergeCoord = '未知';
|
||||
final convergeData = hostData['self_converge_coordinate'];
|
||||
if (convergeData is Map) {
|
||||
final height = convergeData['height'];
|
||||
convergeCoord = 'H:${height}m';
|
||||
}
|
||||
|
||||
// ========== 5. 网络与通信 ==========
|
||||
// 提取 SDR 上行质量(sdr.up_quality)
|
||||
int? sdrUpQuality;
|
||||
final sdrData = hostData['sdr'];
|
||||
if (sdrData is Map) {
|
||||
sdrUpQuality = sdrData['up_quality'] as int?;
|
||||
}
|
||||
|
||||
// 提取 SDR 下行质量(sdr.down_quality)
|
||||
int? sdrDownQuality;
|
||||
if (sdrData is Map) {
|
||||
sdrDownQuality = sdrData['down_quality'] as int?;
|
||||
}
|
||||
|
||||
// 提取 SDR 频段(sdr.frequency_band)
|
||||
double? sdrFreqBand;
|
||||
if (sdrData is Map) {
|
||||
sdrFreqBand = (sdrData['frequency_band'] as num?)?.toDouble();
|
||||
}
|
||||
|
||||
// ========== 6. 其他关键指标 ==========
|
||||
// 提取累计时间(acc_time)
|
||||
int? accTime = hostData['acc_time'] as int?;
|
||||
|
||||
// 提取激活时间(activation_time)
|
||||
int? activationTime = hostData['activation_time'] as int?;
|
||||
|
||||
// 提取倾斜角度(tilt_angle.value)
|
||||
double? tiltAngle;
|
||||
final tiltAngleData = hostData['tilt_angle'];
|
||||
if (tiltAngleData is Map && tiltAngleData['valid'] == 1) {
|
||||
tiltAngle = (tiltAngleData['value'] as num?)?.toDouble();
|
||||
}
|
||||
|
||||
/*debugPrint('✅ [DroneStationOsdCard] 解析结果:');
|
||||
debugPrint(' 无人机电量: $batteryPercent%');
|
||||
debugPrint(' 备用电池: ${backupBatteryTemp}°C / ${backupBatteryVoltage}mV / 开关:$backupBatterySwitch');
|
||||
debugPrint(' 交流功率: $acdcPower W');
|
||||
debugPrint(' 供电电压: $supplyVoltage V');
|
||||
debugPrint(' PoE状态: $poeLinkStatus / 功率: $poePowerOutput W');
|
||||
debugPrint(' 部署模式: $deploymentMode / 作业号: $jobNumber');
|
||||
debugPrint(' 云台状态: $gimbalHolderState');
|
||||
debugPrint(' 维护状态: $maintainStatus');
|
||||
debugPrint(' 备降点: $landPointInfo');
|
||||
debugPrint(' SDR上行: $sdrUpQuality% / 下行: $sdrDownQuality% / 频段: $sdrFreqBand GHz');
|
||||
debugPrint(' 倾斜角度: $tiltAngle°');
|
||||
debugPrint(' 累计时间: $accTime s');*/
|
||||
|
||||
// 构建展示字段列表(精选重要字段)
|
||||
// 🔥 使用缓存机制:有值则更新并缓存,无值则使用上次缓存的值
|
||||
_osdFields = [
|
||||
{
|
||||
'icon': Icons.battery_full_rounded,
|
||||
'label': '无人机电量',
|
||||
'key': 'battery',
|
||||
'newValue': batteryPercent != null ? '${batteryPercent.toInt()}%' : null,
|
||||
'color': _getBatteryColor(batteryPercent),
|
||||
},
|
||||
{
|
||||
'icon': Icons.thermostat_rounded,
|
||||
'label': '备用电池温度',
|
||||
'key': 'backupTemp',
|
||||
'newValue': backupBatteryTemp != null ? '${backupBatteryTemp.toStringAsFixed(1)}°C' : null,
|
||||
'color': const Color(0xFF165DFF),
|
||||
},
|
||||
{
|
||||
'icon': Icons.bolt_rounded,
|
||||
'label': '备用电池电压',
|
||||
'key': 'backupVoltage',
|
||||
'newValue': backupBatteryVoltage != null ? '${(backupBatteryVoltage / 1000).toStringAsFixed(2)}V' : null,
|
||||
'color': const Color(0xFF722ED1),
|
||||
},
|
||||
{
|
||||
'icon': Icons.power_rounded,
|
||||
'label': '交流输入功率',
|
||||
'key': 'acdcPower',
|
||||
'newValue': acdcPower != null ? '${acdcPower.toStringAsFixed(1)} W' : null,
|
||||
'color': const Color(0xFFFF7D00),
|
||||
},
|
||||
{
|
||||
'icon': Icons.electrical_services_rounded,
|
||||
'label': '供电电压',
|
||||
'key': 'supplyVoltage',
|
||||
'newValue': supplyVoltage != null ? '$supplyVoltage V' : null,
|
||||
'color': const Color(0xFF00B42A),
|
||||
},
|
||||
{
|
||||
'icon': Icons.network_check_rounded,
|
||||
'label': 'PoE链路',
|
||||
'key': 'poeLink',
|
||||
'newValue': poeLinkStatus != null ? (poeLinkStatus == 1 ? '已连接' : '未连接') : null,
|
||||
'color': poeLinkStatus == 1 ? const Color(0xFF00B42A) : const Color(0xFF86909C),
|
||||
},
|
||||
{
|
||||
'icon': Icons.settings_rounded,
|
||||
'label': '部署模式',
|
||||
'key': 'deployMode',
|
||||
'newValue': deploymentMode != null ? '模式$deploymentMode' : null,
|
||||
'color': const Color(0xFF165DFF),
|
||||
},
|
||||
{
|
||||
'icon': Icons.work_outline_rounded,
|
||||
'label': '作业编号',
|
||||
'key': 'jobNumber',
|
||||
'newValue': jobNumber != null ? '#$jobNumber' : null,
|
||||
'color': const Color(0xFF00B42A),
|
||||
},
|
||||
{
|
||||
'icon': Icons.camera_roll_rounded,
|
||||
'label': '云台状态',
|
||||
'key': 'gimbalState',
|
||||
'newValue': gimbalHolderState != null ? (gimbalHolderState == 1 ? '已锁定' : '未锁定') : null,
|
||||
'color': gimbalHolderState == 1 ? const Color(0xFF00B42A) : const Color(0xFFF53F3F),
|
||||
},
|
||||
{
|
||||
'icon': Icons.rotate_right_rounded,
|
||||
'label': '倾斜角度',
|
||||
'key': 'tiltAngle',
|
||||
'newValue': tiltAngle != null ? '${tiltAngle.toStringAsFixed(2)}°' : null,
|
||||
'color': const Color(0xFF722ED1),
|
||||
},
|
||||
{
|
||||
'icon': Icons.signal_cellular_alt_rounded,
|
||||
'label': 'SDR上行质量',
|
||||
'key': 'sdrUp',
|
||||
'newValue': sdrUpQuality != null ? '$sdrUpQuality%' : null,
|
||||
'color': sdrUpQuality != null && sdrUpQuality > 80
|
||||
? const Color(0xFF00B42A)
|
||||
: sdrUpQuality != null && sdrUpQuality > 50
|
||||
? const Color(0xFFFF7D00)
|
||||
: const Color(0xFFF53F3F),
|
||||
},
|
||||
{
|
||||
'icon': Icons.wifi_tethering_rounded,
|
||||
'label': 'SDR下行质量',
|
||||
'key': 'sdrDown',
|
||||
'newValue': sdrDownQuality != null ? '$sdrDownQuality%' : null,
|
||||
'color': sdrDownQuality != null && sdrDownQuality > 80
|
||||
? const Color(0xFF00B42A)
|
||||
: sdrDownQuality != null && sdrDownQuality > 50
|
||||
? const Color(0xFFFF7D00)
|
||||
: const Color(0xFFF53F3F),
|
||||
},
|
||||
{
|
||||
'icon': Icons.access_time_rounded,
|
||||
'label': '累计时间',
|
||||
'key': 'accTime',
|
||||
'newValue': accTime != null ? '${(accTime / 3600).toStringAsFixed(1)}h' : null,
|
||||
'color': const Color(0xFF86909C),
|
||||
},
|
||||
{
|
||||
'icon': Icons.calendar_today_rounded,
|
||||
'label': '激活时间',
|
||||
'key': 'activationTime',
|
||||
'newValue': activationTime != null ? _formatTimestamp(activationTime) : null,
|
||||
'color': const Color(0xFF86909C),
|
||||
},
|
||||
];
|
||||
|
||||
// 🔥 应用缓存逻辑:有新值则更新缓存,否则使用旧值
|
||||
for (var field in _osdFields) {
|
||||
final key = field['key'] as String;
|
||||
final newValue = field['newValue'] as String?;
|
||||
|
||||
if (newValue != null && newValue != '未知') {
|
||||
// 有新值,更新缓存
|
||||
_cachedValues[key] = newValue;
|
||||
field['value'] = newValue;
|
||||
} else {
|
||||
// 无新值,使用缓存值或默认"未知"
|
||||
field['value'] = _cachedValues[key] ?? '未知';
|
||||
}
|
||||
}
|
||||
|
||||
//debugPrint('✅ [DroneStationOsdCard] 共解析 ${_osdFields.length} 个字段');
|
||||
}
|
||||
|
||||
Color _getBatteryColor(dynamic battery) {
|
||||
if (battery == null) return const Color(0xFF86909C);
|
||||
|
||||
final value = battery is num ? battery.toDouble() : 0.0;
|
||||
if (value > 50) return const Color(0xFF00B42A);
|
||||
if (value > 20) return const Color(0xFFFF7D00);
|
||||
return const Color(0xFFF53F3F);
|
||||
}
|
||||
|
||||
/// 格式化 Unix 时间戳(秒)为日期字符串
|
||||
String _formatTimestamp(int timestamp) {
|
||||
try {
|
||||
final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
|
||||
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}';
|
||||
} catch (e) {
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.isOnline) {
|
||||
return _buildOfflineCard();
|
||||
}
|
||||
|
||||
if (_osdFields.isEmpty) {
|
||||
return _buildLoadingCard();
|
||||
}
|
||||
|
||||
return _buildOsdGridCard();
|
||||
}
|
||||
|
||||
/// 离线状态卡片
|
||||
Widget _buildOfflineCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_off_rounded, size: 48, color: const Color(0xFF86909C)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text(
|
||||
'机场离线',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'暂无实时数据',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFF86909C)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 加载中卡片
|
||||
Widget _buildLoadingCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// OSD 网格布局卡片(核心功能)
|
||||
Widget _buildOsdGridCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.analytics_rounded, size: 20, color: const Color(0xFF165DFF)),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'实时数据',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 网格布局展示所有字段
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 0.85,
|
||||
),
|
||||
itemCount: _osdFields.length,
|
||||
itemBuilder: (context, index) {
|
||||
final field = _osdFields[index];
|
||||
return _buildOsdGridItem(field);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// OSD 网格项
|
||||
Widget _buildOsdGridItem(Map<String, dynamic> field) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: (field['color'] as Color).withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: (field['color'] as Color).withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 图标
|
||||
Icon(
|
||||
field['icon'] as IconData,
|
||||
color: field['color'] as Color,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 标签
|
||||
Text(
|
||||
field['label'] as String,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// 数值
|
||||
Text(
|
||||
field['value'] as String,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: field['color'] as Color,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
|
||||
import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart';
|
||||
|
||||
/// 机器人顶部信息卡片
|
||||
class RobotHeaderCard extends StatelessWidget {
|
||||
class RobotHeaderCard extends StatefulWidget {
|
||||
final Map<String, dynamic> robot;
|
||||
|
||||
const RobotHeaderCard({super.key, required this.robot});
|
||||
|
||||
@override
|
||||
State<RobotHeaderCard> createState() => _RobotHeaderCardState();
|
||||
}
|
||||
|
||||
class _RobotHeaderCardState extends State<RobotHeaderCard> {
|
||||
String _videoStreamUrl = '';
|
||||
int _currentViewIndex = 0; // 0=前, 1=后, 2=左, 3=右, 4=上
|
||||
|
||||
// 视角配置
|
||||
final List<Map<String, dynamic>> _viewConfigs = [
|
||||
{'name': '前视', 'alignment': Alignment.topLeft, 'icon': Icons.arrow_upward},
|
||||
{'name': '后视', 'alignment': Alignment.topRight, 'icon': Icons.arrow_downward},
|
||||
{'name': '左视', 'alignment': Alignment.bottomLeft, 'icon': Icons.arrow_back},
|
||||
{'name': '右视', 'alignment': Alignment.bottomRight, 'icon': Icons.arrow_forward},
|
||||
{'name': '俯视', 'alignment': Alignment.center, 'icon': Icons.view_agenda},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initVideoUrl();
|
||||
}
|
||||
|
||||
/// 初始化视频流 URL
|
||||
void _initVideoUrl() {
|
||||
final userState = context.read<AppUserCubit>().state;
|
||||
final deviceId = widget.robot['id'] as String?;
|
||||
|
||||
debugPrint(' [RobotHeaderCard] 开始初始化视频URL');
|
||||
debugPrint('🎬 [RobotHeaderCard] deviceId: $deviceId');
|
||||
debugPrint('🎬 [RobotHeaderCard] user: ${userState.user}');
|
||||
debugPrint('🎬 [RobotHeaderCard] token: ${userState.user?.token}');
|
||||
|
||||
if (deviceId != null && deviceId.isNotEmpty && userState.user != null && userState.user!.token != null) {
|
||||
setState(() {
|
||||
_videoStreamUrl = "webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=${userState.user!.token}";
|
||||
});
|
||||
debugPrint('✅ [RobotHeaderCard] 视频URL初始化成功: $_videoStreamUrl');
|
||||
} else {
|
||||
debugPrint('❌ [RobotHeaderCard] 视频URL初始化失败');
|
||||
debugPrint(' - deviceId.isEmpty: ${deviceId == null || deviceId.isEmpty}');
|
||||
debugPrint(' - user == null: ${userState.user == null}');
|
||||
debugPrint(' - token == null: ${userState.user?.token == null}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
@@ -31,7 +81,7 @@ class RobotHeaderCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
robot['name'] as String,
|
||||
widget.robot['name'] as String,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -40,7 +90,7 @@ class RobotHeaderCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'ID: ${robot['id']}',
|
||||
'ID: ${widget.robot['id']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF86909C),
|
||||
@@ -92,14 +142,81 @@ class RobotHeaderCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 机器人图片
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.asset(
|
||||
'assets/images/xunjian.png',
|
||||
height: 180,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
// 🔥 视频展示区域(替换原来的静态图片)
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
color: Colors.black, // 🔥 强制整个视频区域为黑色背景
|
||||
child: _videoStreamUrl.isNotEmpty
|
||||
? WebRTCLocalPlayer(
|
||||
streamUrl: _videoStreamUrl,
|
||||
showLeftPip: false, // 不显示悬浮小窗
|
||||
showRightPip: false,
|
||||
isFrontMain: _currentViewIndex == 0, // 根据当前视角决定主画面
|
||||
)
|
||||
: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.videocam_off, size: 48, color: Colors.white54),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'无视频信号',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 🔥 视角切换按钮
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 12, 0, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: _viewConfigs.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final config = entry.value;
|
||||
final isSelected = _currentViewIndex == index;
|
||||
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_currentViewIndex = index;
|
||||
});
|
||||
debugPrint('🎬 [RobotHeaderCard] 切换视角: ${config['name']}');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSelected ? const Color(0xFF165DFF) : Colors.grey[200],
|
||||
foregroundColor: isSelected ? Colors.white : Colors.grey[700],
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
minimumSize: const Size(0, 36),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(config['icon'] as IconData, size: 16),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
config['name'] as String,
|
||||
style: const TextStyle(fontSize: 10),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user