对接远程遥控

This commit is contained in:
2026-06-05 19:06:21 +08:00
parent d8879dad62
commit 7c9734d918
53 changed files with 4683 additions and 1612 deletions

View File

@@ -1,12 +1,16 @@
allprojects {
repositories {
// 使用阿里云镜像加速(优先)
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
maven { url = uri("https://maven.aliyun.com/repository/public") }
google()
mavenCentral()
// 火山引擎 RTC 仓库已禁用
// maven {
// url = uri("https://artifact.bytedance.com/repository/Volcengine/")
// allowInsecureProtocol = true
// }
// 火山引擎 RTC 仓库
maven { url = uri("https://artifact.bytedance.com/repository/Volcengine/") }
// BytePlus 公共仓库(包含火山引擎依赖)
maven { url = uri("https://artifact.byteplus.com/repository/public/") }
}
}

View File

@@ -1,16 +1,19 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dhttps.protocols=TLSv1.2,TLSv1.1 -Djava.net.preferIPv4Stack=true
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dhttps.protocols=TLSv1.3,TLSv1.2,TLSv1.1 -Djava.net.preferIPv4Stack=true -Djavax.net.ssl.trustStoreType=JKS
android.useAndroidX=true
# \u4F7F\u7528\u963F\u91CC\u4E91\u955C\u50CF\u52A0\u901F
# 使用阿里云镜像加速
android.enableJetifier=true
# \u7981\u7528\u4EE3\u7406\uFF08\u8986\u76D6\u5168\u5C40\u914D\u7F6E\uFF09
# 禁用代理(覆盖全局配置)
systemProp.http.proxyHost=
systemProp.http.proxyPort=
systemProp.https.proxyHost=
systemProp.https.proxyPort=
# \u653E\u884CSSL\u8BC1\u4E66\u4E0E\u534F\u8BAE\uFF0C\u89E3\u51B3TLS\u63E1\u624B\u5F02\u5E38
# 放行SSL证书与协议,解决TLS握手异常
systemProp.http.ssl.insecure=true
systemProp.http.ssl.allowall=true
systemProp.http.ssl.ignore.validity.dates=true
# Android SDK 镜像配置(使用清华大学镜像)
systemProp.android.sdkmanager.channel=stable

View File

@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/core/network/tcp/tcp_status_cubit.dart';
class TcpStatusIndicator extends StatefulWidget {
final double size;
final bool showDisconnected; // 是否显示未连接状态
const TcpStatusIndicator({
super.key,
this.size = 12.0,
this.showDisconnected = false, // 默认不显示未连接状态
});
@override
State<TcpStatusIndicator> createState() => _TcpStatusIndicatorState();
}
class _TcpStatusIndicatorState extends State<TcpStatusIndicator>
with SingleTickerProviderStateMixin {
late TcpStatusCubit _tcpStatusCubit;
bool _hasActivity = false;
AnimationController? _controller;
Animation<double>? _animation;
@override
void initState() {
super.initState();
_tcpStatusCubit = GetIt.I<TcpStatusCubit>();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
_animation = Tween<double>(begin: 0.6, end: 1.0).animate(
CurvedAnimation(parent: _controller!, curve: Curves.easeInOut),
);
_controller!.repeat(reverse: true);
_tcpStatusCubit.stream.listen((state) {
if (state.status != TcpConnectionStatus.disconnected) {
_hasActivity = true;
}
if (mounted) {
setState(() {});
}
});
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = _tcpStatusCubit.state;
if (!widget.showDisconnected &&
!_hasActivity &&
state.status == TcpConnectionStatus.disconnected) {
return const SizedBox.shrink();
}
Color color;
String tooltip;
bool shouldAnimate;
switch (state.status) {
case TcpConnectionStatus.connected:
color = Colors.green;
tooltip = 'TCP已连接';
shouldAnimate = false;
break;
case TcpConnectionStatus.connecting:
color = Colors.yellow;
tooltip = 'TCP连接中...';
shouldAnimate = true;
break;
case TcpConnectionStatus.error:
color = Colors.red;
tooltip = state.errorMessage ?? 'TCP连接错误';
shouldAnimate = false;
break;
case TcpConnectionStatus.disconnected:
default:
color = Colors.grey;
tooltip = 'TCP未连接';
shouldAnimate = false;
break;
}
return Tooltip(
message: tooltip,
child: AnimatedBuilder(
animation: _animation!,
builder: (context, child) {
final opacity = shouldAnimate ? _animation!.value : 1.0;
return Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: color.withOpacity(opacity),
shape: BoxShape.circle,
boxShadow: state.status == TcpConnectionStatus.connected
? [
BoxShadow(
color: Colors.green.withOpacity(0.5 * opacity),
blurRadius: 6 * opacity,
spreadRadius: 2 * opacity,
),
]
: [],
),
);
},
),
);
}
}

View File

@@ -7,7 +7,9 @@ class AppUserCubit extends Cubit<AppUserState> {
AppUserCubit() : super(const AppUserState());
void setAuth(UserEntity user) {
print('✅ [AppUserCubit] setAuth 被调用,用户: ${user.username}');
emit(state.copyWith(user));
print('✅ [AppUserCubit] 状态已更新,当前用户: ${state.user?.username}');
}
void clearAuth() {

View File

@@ -1,4 +1,4 @@
class TCPConsts {
static const String TCP_IP = "1.95.137.212";
static const int TCP_PORT = 9001;
static const int TCP_PORT = 59016;
}

View File

@@ -20,6 +20,7 @@ import 'package:maibu_satabot_v2/features/main_container/presentation/cubit/tab_
import 'package:maibu_satabot_v2/features/remote_control/data/repositories/remote_control_repository_impl.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/repositories/remote_control_repository.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/usecase/diff_steer_usecase.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/usecase/request_control_permission_usecase.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../features/auth/data/datasources/auth_http_datasource.dart';
@@ -111,6 +112,7 @@ import '../../features/home/presentation/bloc/permission_request_bloc.dart';
import '../network/dio_client.dart';
import '../network/net_message_dispatcher.dart';
import '../network/tcp/tcp_client.dart';
import '../network/tcp/tcp_status_cubit.dart';
import '../router/app_router.dart';
import '../storage/impl/user_storage_impl.dart';
import '../storage/user_storage.dart';
@@ -258,7 +260,9 @@ Future<void> init() async {
sl.registerLazySingleton<GetHomeDataUseCase>(() => GetHomeDataUseCase(sl()));
// Site (场站)
sl.registerLazySingleton<SiteDataSource>(() => SiteDataSourceImpl(sl<Dio>()));
sl.registerLazySingleton<SiteDataSource>(
() => SiteDataSourceImpl(sl<Dio>(), sl<UserStorage>(), sl<AppUserCubit>()),
);
sl.registerLazySingleton<SiteRepository>(() => SiteRepositoryImpl(sl()));
sl.registerLazySingleton<GetSiteListUseCase>(() => GetSiteListUseCase(sl()));
@@ -301,9 +305,7 @@ Future<void> init() async {
);
/// Robot List V2
sl.registerFactory<RobotListBloc>(
() => RobotListBloc(sl<Dio>()),
);
sl.registerFactory<RobotListBloc>(() => RobotListBloc(sl<Dio>()));
/// Alarm Center V2
sl.registerLazySingleton<AlarmRemoteDataSource>(
@@ -333,9 +335,7 @@ Future<void> init() async {
sl.registerLazySingleton<ConfirmAlarmUseCase>(
() => ConfirmAlarmUseCase(sl()),
);
sl.registerLazySingleton<AIDiagnosisUseCase>(
() => AIDiagnosisUseCase(sl()),
);
sl.registerLazySingleton<AIDiagnosisUseCase>(() => AIDiagnosisUseCase(sl()));
sl.registerFactory<AlarmDetailCubit>(
() => AlarmDetailCubit(
getAlarmDetailUseCase: sl(),
@@ -347,6 +347,9 @@ Future<void> init() async {
/// 5. 状态管理 (Cubit/Bloc)
sl.registerLazySingleton(() => AppUserCubit()); // AuthCubit 依赖它,必须先注册
// TCP状态管理 Cubit
sl.registerLazySingleton(() => TcpStatusCubit());
// 🔥 SiteCubit (全局共享,持久化选中场站)
sl.registerLazySingleton(() => SiteCubit(sl<SharedPreferences>()));
@@ -386,7 +389,7 @@ Future<void> init() async {
);
// 🔥 RemoteControlCubit 注入 DeviceStatusBloc(工厂模式,每次新建)
sl.registerFactory(() => RemoteControlCubit(sl(), sl(), sl(), sl()));
sl.registerFactory(() => RemoteControlCubit(sl(), sl(), sl(), sl(), sl()));
// 🔥 PermissionRequestBloc 用于首页权限弹窗(单例,通过 NetMessageDispatcher 监听)
sl.registerLazySingleton(

View File

@@ -17,7 +17,7 @@ import '../../../features/devices/domain/usecases/switch_device_usecase.dart';
import '../../logging/i_logger_service.dart';
import '../../storage/user_storage.dart';
import '../protocol_decoder.dart';
import 'tcp_status_cubit.dart';
class TcpClient {
Socket? _socket;
@@ -27,11 +27,14 @@ class TcpClient {
Stream<RawPacket> get packetStream => _controller.stream;
final GetUserDeviceUseCase getUserDeviceUseCase;
final SwitchDeviceUseCase switchDeviceUseCase;
TcpClient(this._userStorage, {required this.getUserDeviceUseCase, required this.switchDeviceUseCase});
TcpClient(
this._userStorage, {
required this.getUserDeviceUseCase,
required this.switchDeviceUseCase,
});
final ILoggerService _logger = GetIt.I<ILoggerService>();
// 新增:心跳定时器
Timer? _heartbeatTimer;
Timer? _reconnectTimer; // 重连定时器
@@ -60,12 +63,64 @@ class TcpClient {
_logger.logWithLevel('🔌 [TCP] 物理断开 Socket...', shouldLog: true);
_socket!.destroy(); // 或者 .close()
_socket = null;
// 更新连接状态为未连接
try {
GetIt.I<TcpStatusCubit>().setDisconnected();
} catch (e) {
_logger.logWithLevel('❌ [TCP] TcpStatusCubit 未注册', shouldLog: false);
}
}
}
/// 🔥 彻底断开 TCP(用于退出登录),清除所有重连能力
void forceDisconnect() {
_logger.logWithLevel('🛑 [TCP] 强制断开并清除重连能力', shouldLog: true);
// 1. 停止心跳
stopHeartbeat();
// 2. 取消重连定时器
_reconnectTimer?.cancel();
_reconnectTimer = null;
// 3. 清除 Host/Port,防止重连
_lastHost = null;
_lastPort = null;
// 4. 重置标志
isUserSwitch = false;
_isSwitching = false;
// 5. 销毁 Socket
if (_socket != null) {
_socket!.destroy();
_socket = null;
_logger.logWithLevel('✅ [TCP] Socket 已物理销毁', shouldLog: true);
}
// 6. 更新状态
try {
GetIt.I<TcpStatusCubit>().setDisconnected();
} catch (e) {
_logger.logWithLevel('❌ [TCP] TcpStatusCubit 未注册', shouldLog: false);
}
_logger.logWithLevel('✅ [TCP] 已彻底断开,下次登录需重新初始化', shouldLog: true);
}
// 通过参数配置,不硬编码
Future<void> connect({required String host, required int port}) async {
//debugPrint('🔌 [TCP] 开始连接:$host:$port'); // ✅ 必须看到这条
_logger.logWithLevel('🔌 [TCP] 开始连接:$host:$port', shouldLog: true);
// 更新连接状态为连接中
try {
GetIt.I<TcpStatusCubit>().setConnecting();
} catch (e) {
_logger.logWithLevel('❌ [TCP] TcpStatusCubit 未注册', shouldLog: false);
}
_lastHost = host;
_lastPort = port;
try {
@@ -76,65 +131,90 @@ class TcpClient {
);
// 🔥 关键修复:禁用Nagle算法,确保小包立即发送
_socket!.setOption(SocketOption.tcpNoDelay, true);
// debugPrint('✅ [TCP] 连接成功!'); // ✅ 必须看到这条
// debugPrint('✅ [TCP] 连接成功!'); // ✅ 必须看到这条
_logger.logWithLevel('✅ [TCP] 连接成功!', shouldLog: true);
// 开始认证tcp
await _sendAuthPacket();
_socket!.listen((data) {
//debugPrint('📥 [TCP] 收到原始数据:${data.length} 字节, 内容:$data');
_logger.logWithLevel('✅ [TCP] 监听数据...');
// var packets = _decoder.decode(data);
// debugPrint('📦 [TCP] 解码成功,包数量:${packets.length}');
//for (var packet in packets) {
// // 🔥若收到服务端心跳(cmd == 0xFF),立即回复一个心跳包
// if (packet.command == 0xFF) {
// 更新连接状态为已连接
try {
GetIt.I<TcpStatusCubit>().setConnected();
} catch (e) {
_logger.logWithLevel('❌ [TCP] TcpStatusCubit 未注册', shouldLog: false);
}
// 开始认证tcp
await _sendAuthPacket();
_socket!.listen(
(data) {
//debugPrint('📥 [TCP] 收到原始数据:${data.length} 字节, 内容:$data');
_logger.logWithLevel('✅ [TCP] 监听数据...');
// var packets = _decoder.decode(data);
// debugPrint('📦 [TCP] 解码成功,包数量:${packets.length}');
//for (var packet in packets) {
// // 🔥若收到服务端心跳(cmd == 0xFF),立即回复一个心跳包
// if (packet.command == 0xFF) {
// debugPrint('收到服务端心跳,自动回复...');
// sendHeartbeat(); // 回复 AB AA FF AA AB
// sendHeartbeat(); // 回复 AB AA FF AA AB
// }
// }
// _controller.add(packet);
// }
try {
var packets = _decoder.decode(data);
//debugPrint('📦 [TCP] 解码成功,包数量:${packets.length}');
_logger.logWithLevel('✅ [TCP] 解码成功,包数量:${packets.length}',shouldLog: false);
for (var packet in packets) {
// 🔥 最根部日志:收到任何推送都打印
// debugPrint('📥 [TCP-ROOT] 收到推送 CMD: 0x${packet.command.toRadixString(16).toUpperCase()}, Payload长度: ${packet.payload.length}');
_logger.logWithLevel('📥 [TCP-ROOT] 收到推送 CMD: 0x${packet.command.toRadixString(16).toUpperCase()}, Payload长度: ${packet.payload.length}', shouldLog: true);
// }
// _controller.add(packet);
// }
try {
var packets = _decoder.decode(data);
//debugPrint('📦 [TCP] 解码成功,包数量:${packets.length}');
_logger.logWithLevel(
'✅ [TCP] 解码成功,包数量:${packets.length}',
shouldLog: false,
);
for (var packet in packets) {
// 🔥 最根部日志:收到任何推送都打印
// debugPrint('📥 [TCP-ROOT] 收到推送 CMD: 0x${packet.command.toRadixString(16).toUpperCase()}, Payload长度: ${packet.payload.length}');
// _logger.logWithLevel(
// '📥 [TCP-ROOT] 收到推送 CMD: 0x${packet.command.toRadixString(16).toUpperCase()}, Payload长度: ${packet.payload.length}',
// shouldLog: true,
// );
if (!_controller.isClosed) {
_controller.add(packet);
//debugPrint('➡️ [TCP] 已分发 CMD: 0x${packet.command.toRadixString(16)}');
_logger.logWithLevel('✅ [TCP] 已分发 CMD: 0x${packet.command.toRadixString(16)}' ,shouldLog: false);
}
if (!_controller.isClosed) {
_controller.add(packet);
//debugPrint('➡️ [TCP] 已分发 CMD: 0x${packet.command.toRadixString(16)}');
_logger.logWithLevel(
'✅ [TCP] 已分发 CMD: 0x${packet.command.toRadixString(16)}',
shouldLog: false,
);
}
if (packet.command == 0xFF) {
//debugPrint('收到服务端心跳,自动回复...');
_logger.logWithLevel('✅ [TCP] 收到服务端心跳,自动回复...',shouldLog: false);
sendHeartbeat(); // 回复 AB AA FF AA AB
//debugPrint('收到服务端心跳,自动回复...');
_logger.logWithLevel(
'✅ [TCP] 收到服务端心跳,自动回复...',
shouldLog: false,
);
sendHeartbeat(); // 回复 AB AA FF AA AB
}
if (packet.command == 0x03) {
// debugPrint('⚠️ 收到认证响应:${packet.payload}');
_logger.logWithLevel(
'⚠️ 收到认证响应:${packet.payload}',
shouldLog: false,
);
// 解析 payload 看是否有错误信息
}
}
if (packet.command == 0x03) {
// debugPrint('⚠️ 收到认证响应:${packet.payload}');
_logger.logWithLevel('⚠️ 收到认证响应:${packet.payload}',shouldLog: false);
// 解析 payload 看是否有错误信息
}
} catch (e, stackTrace) {
// debugPrint('❌ [TCP] 解码数据时发生异常:$e\n$stackTrace'); // 🔥 捕获解码异常
_logger.logWithLevel(
'❌ [TCP] 解码数据时发生异常:$e\n$stackTrace',
shouldLog: false,
);
}
} catch (e, stackTrace) {
// debugPrint('❌ [TCP] 解码数据时发生异常:$e\n$stackTrace'); // 🔥 捕获解码异常
_logger.logWithLevel('❌ [TCP] 解码数据时发生异常:$e\n$stackTrace', shouldLog: false);
}
},
onDone: (){
// TODO: 断线重连
// debugPrint('来到断线重连!');
onDone: () {
// TODO: 断线重连
// debugPrint('来到断线重连!');
if (!_isSwitching) {
//debugPrint('onDone❌ [TCP] 连接已断开!');
_logger.logWithLevel('❌ [TCP] 连接已断开!', shouldLog: false);
_handleDisconnect();
}
_handleDisconnect();
}
_isSwitching = false; // 重置标志,以免影响下次
},
onError: (e) {
//debugPrint('来到断线重连!error');
@@ -145,14 +225,14 @@ class TcpClient {
_handleDisconnect();
} // 统一走重连逻辑,保护 Controller 不被关闭
_isSwitching = false; // 重置标志,以免影响下次
},
);
} catch (e) {
rethrow; // 向上抛出连接异常
}
}
//
//
void _handleDisconnect() {
stopHeartbeat();
@@ -167,10 +247,10 @@ class TcpClient {
if (isUserSwitch) return;
if (_reconnectTimer != null) return;
// debugPrint('⏳ 调度重连:Host=${_lastHost}, Port=${_lastPort}'); // ✅ 检查 Host/Port 是否为空
// debugPrint('⏳ 调度重连:Host=${_lastHost}, Port=${_lastPort}'); // ✅ 检查 Host/Port 是否为空
_logger.logWithLevel('⏳ 调度重连:Host=${_lastHost}, Port=${_lastPort}');
if (_lastHost == null || _lastPort == null) {
//debugPrint('❌ 无法重连:Host 或 Port 为空!');
//debugPrint('❌ 无法重连:Host 或 Port 为空!');
_logger.logWithLevel('❌ 无法重连:Host 或 Port 为空!', shouldLog: true);
return;
}
@@ -181,17 +261,14 @@ class TcpClient {
_logger.logWithLevel('⏰ 定时器触发,开始执行重连...', shouldLog: true);
connect(host: _lastHost!, port: _lastPort!);
});
await _sendAuthPacket();
await _sendAuthPacket();
}
// ✅ 新增:调度重连
Future<void> _scheduleReconnectBySwitch(devname) async {
//debugPrint('⏳ 被动调度重连:Host=${_lastHost}, Port=${_lastPort}'); // ✅ 检查 Host/Port 是否为空
if (_lastHost == null || _lastPort == null) {
// debugPrint('❌ 无法重连:Host 或 Port 为空!');
// debugPrint('❌ 无法重连:Host 或 Port 为空!');
_logger.logWithLevel('❌ 无法重连:Host 或 Port 为空!', shouldLog: true);
return;
}
@@ -202,19 +279,19 @@ class TcpClient {
_logger.logWithLevel('⏰ 被动-定时器触发,开始执行重连...', shouldLog: true);
connectBySwitch(host: _lastHost!, port: _lastPort!, deviceName: devname);
});
await _sendAuthPacketBySwitch(devname);
await _sendAuthPacketBySwitch(devname);
}
/// 发送数据
void send(Map<String, dynamic> data) {
if (_socket == null) throw Exception("Socket not connected");
_socket!.write(jsonEncode(data));
}
void sendnew(List<int> data){
void sendnew(List<int> data) {
_socket!.add(data);
}
void sendRaw(int cmd, List<int> payload) {
if (_socket == null) return;
@@ -232,19 +309,16 @@ class TcpClient {
_socket!.flush();
}
void sendHeartbeat() {
if (_socket == null) return;
final builder = BytesBuilder()
..addByte(0xAB)
..addByte(0xAA)
..addByte(0xFF)
..addByte(0xAA)
..addByte(0xAB);
_socket!.add(builder.takeBytes());
}
void sendHeartbeat() {
if (_socket == null) return;
final builder = BytesBuilder()
..addByte(0xAB)
..addByte(0xAA)
..addByte(0xFF)
..addByte(0xAA)
..addByte(0xAB);
_socket!.add(builder.takeBytes());
}
// 新增:启动心跳(每 4 秒发送一次 0xFF 指令)
void startHeartbeat({Duration interval = const Duration(seconds: 4)}) {
@@ -259,13 +333,124 @@ class TcpClient {
debugPrint("TCP Connected");
}
/// 🔥 封装完整的TCP初始化方法:连接 + 认证 + 心跳
/// 在登录成功后调用此方法即可建立并维护TCP连接
Future<void> initializeTcp({required String host, required int port}) async {
try {
// 1. 如果已经连接,先断开
if (_socket != null) {
_logger.logWithLevel('🔄 [TCP] 已有连接,先断开重新连接...', shouldLog: true);
disconnect();
await Future.delayed(const Duration(milliseconds: 500));
}
// 2. 更新状态为连接中
try {
GetIt.I<TcpStatusCubit>().setConnecting();
} catch (e) {
_logger.logWithLevel('❌ [TCP] TcpStatusCubit 未注册', shouldLog: false);
}
// 3. 执行连接
_logger.logWithLevel('🔌 [TCP] 开始初始化连接:$host:$port', shouldLog: true);
_lastHost = host;
_lastPort = port;
_socket = await Socket.connect(
host,
port,
timeout: const Duration(seconds: 5),
);
_socket!.setOption(SocketOption.tcpNoDelay, true);
_logger.logWithLevel('✅ [TCP] Socket 连接成功', shouldLog: true);
// 4. 更新状态为已连接
try {
GetIt.I<TcpStatusCubit>().setConnected();
} catch (e) {
_logger.logWithLevel('❌ [TCP] TcpStatusCubit 未注册', shouldLog: false);
}
// 5. 发送认证包
await _sendAuthPacket();
_logger.logWithLevel('✅ [TCP] 认证包已发送', shouldLog: true);
// 6. 启动心跳
startHeartbeat();
_logger.logWithLevel('✅ [TCP] 心跳已启动', shouldLog: true);
// 7. 设置数据监听(复用原有的监听逻辑)
_setupDataListener();
_logger.logWithLevel('🎉 [TCP] TCP 初始化完成!', shouldLog: true);
} catch (e) {
_logger.logWithLevel('❌ [TCP] 初始化失败:$e', shouldLog: true);
try {
GetIt.I<TcpStatusCubit>().setError(e.toString());
} catch (_) {}
rethrow;
}
}
/// 设置数据监听器
void _setupDataListener() {
if (_socket == null) return;
_socket!.listen(
(data) {
try {
var packets = _decoder.decode(data);
for (var packet in packets) {
// 🔥 最根部日志:收到任何推送都打印
// debugPrint('📥 [TCP-ROOT] 收到推送 CMD: 0x${packet.command.toRadixString(16).toUpperCase()}, Payload长度: ${packet.payload.length}');
// _logger.logWithLevel(
// '📥 [TCP-ROOT] 收到推送 CMD: 0x${packet.command.toRadixString(16).toUpperCase()}, Payload长度: ${packet.payload.length}',
// shouldLog: true,
// );
if (!_controller.isClosed) {
_controller.add(packet);
}
if (packet.command == 0xFF) {
sendHeartbeat();
}
if (packet.command == 0x03) {
_logger.logWithLevel(
'⚠️ 收到认证响应:${packet.payload}',
shouldLog: true,
);
}
}
} catch (e, stackTrace) {
_logger.logWithLevel(
'❌ [TCP] 解码数据时发生异常:$e\n$stackTrace',
shouldLog: true,
);
}
},
onDone: () {
if (!_isSwitching) {
_logger.logWithLevel('❌ [TCP] 连接已断开!', shouldLog: false);
_handleDisconnect();
}
_isSwitching = false;
},
onError: (e) {
_logger.logWithLevel('❌ [TCP] 发生错误:$e', shouldLog: true);
if (!_isSwitching) {
_handleDisconnect();
}
_isSwitching = false;
},
);
}
// 新增:停止心跳
void stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
// void disconnect() {
// stopHeartbeat(); // 先停心跳
// _socket?.destroy();
@@ -299,23 +484,21 @@ class TcpClient {
// ❌ 绝对不要关闭 _controller!否则数据流断裂,重连后收不到数据
// _controller.close();
// debugPrint('✅ [TCP] 连接已断开,等待手动重连');
// debugPrint('✅ [TCP] 连接已断开,等待手动重连');
_logger.logWithLevel('✅ [TCP] 断开连接成功');
}
void sendPathPoint(RoutePlanSendEntity routePlanSendEntity) {
if (_socket == null) return;
final payload = routePlanSendEntity.toBytes();
// print("sendPathPoint-0x01开始发送路径点数据");
// print("sendPathPoint-0x01开始发送路径点数据");
_logger.logWithLevel("sendPathPoint-0x01开始发送路径点数据");
//sendRaw(0x01, payload); // 第1种的测试 声明指令结构和类型0x01 为命令类型
sendnew(payload); // 第1种的测试 声明指令结构和类型0x01 为命令类型
// _socket!.add(payload);//第二种的测试
//s print("底层发送指令完成");
// _socket!.add(payload);//第二种的测试
//s print("底层发送指令完成");
_logger.logWithLevel("底层发送指令完成");
}
}
void sendDeviceStateChange(RoutePlanSendEntity routePlanSendEntity) {
if (_socket == null) return;
@@ -333,21 +516,21 @@ class TcpClient {
// ..addByte(0xAB);
//
// final packet = builder.takeBytes();
sendnew(payload);
sendnew(payload);
// _socket!.add(packet);
var counts = routePlanSendEntity.pointCounts;
//print("sendDeviceStateChange 底层发送指令完成,数据:$counts");
_logger.logWithLevel("sendDeviceStateChange 底层发送指令完成,数据:$counts");
/// print("📡 发送的完整数据包:${builder.takeBytes()}");
}
/// print("📡 发送的完整数据包:${builder.takeBytes()}");
}
//
Future<void> _sendAuthPacket() async {
if (_socket == null) return;
String? username= "";
String? token= "";
String? username = "";
String? token = "";
final user = await _userStorage.getUser();
//debugPrint('tcp使用用户信息:$user');
_logger.log('tcp使用用户信息:$user');
@@ -381,57 +564,57 @@ class TcpClient {
_socket!.add(builder.takeBytes());
//debugPrint('🔑 [TCP] 已发送认证包 (0x03): $authString');
_logger.log('🔑 [TCP] 已发送认证包 (0x03): $authString');
_logger.logWithLevel('🔑 [TCP] 已发送认证包 (0x03): $authString');
try {
// 1. 获取 Either 结果
final eitherResult = await getUserDeviceUseCase.repository.getUserDevice(username);
try {
// 1. 获取 Either 结果
final eitherResult = await getUserDeviceUseCase.repository.getUserDevice(username);
// 2. 使用 fold 解包 Either
// left: 处理错误情况 (DeviceFailure)
// right: 处理成功情况 (List<DeviceEntity>)
await eitherResult.fold(
(failure) {
// 处理失败:打印日志或抛出异常
// debugPrint('❌ [AuthTcp] 获取设备列表失败:$failure');
_logger.logWithLevel('❌ [AuthTcp] 获取设备列表失败:$failure');
// _sendAuthPacket();
throw Exception('获取设备列表失败:$failure');
},
(devices) async {
// 处理成功:devices 现在是真正的 List<DeviceEntity>
if (devices.isEmpty) {
// debugPrint('⚠️ [AuthTcp] 当前用户无可用设备,跳过切换步骤');
_logger.logWithLevel('⚠️ [AuthTcp] 当前用户无可用设备,跳过切换步骤');
return;
}
// 取第一个设备
final DeviceEntity targetDevice = devices.first;
// debugPrint('📱 [AuthTcp] 准备切换至默认设备:${targetDevice.deviceName}');
_logger.logWithLevel('📱 [AuthTcp] 准备切换至默认设备:${targetDevice.deviceName}');
// 切换设备
final switchResult = await switchDeviceUseCase.deviceRepository.switchDevice("app",targetDevice.deviceName);
await switchResult.fold(
// 2. 使用 fold 解包 Either
// left: 处理错误情况 (DeviceFailure)
// right: 处理成功情况 (List<DeviceEntity>)
await eitherResult.fold(
(failure) {
// debugPrint('❌ [AuthTcp] 切换设备失败:$failure');
_logger .logWithLevel('❌ [AuthTcp] 切换设备失败:$failure');
// 处理失败:打印日志或抛出异常
// debugPrint('❌ [AuthTcp] 获取设备列表失败:$failure');
_logger.logWithLevel('❌ [AuthTcp] 获取设备列表失败:$failure');
// _sendAuthPacket();
throw Exception('切换设备失败:$failure');
throw Exception('获取设备列表失败:$failure');
},
(success) {
// debugPrint('✅ [AuthTcp] 设备切换成功,服务端应开始推送数据');
_logger.logWithLevel('✅ [AuthTcp] 设备切换成功,服务端应开始推送数据');
(devices) async {
// 处理成功:devices 现在是真正的 List<DeviceEntity>
if (devices.isEmpty) {
// debugPrint('⚠️ [AuthTcp] 当前用户无可用设备,跳过切换步骤');
_logger.logWithLevel('⚠️ [AuthTcp] 当前用户无可用设备,跳过切换步骤');
return;
}
// 取第一个设备
final DeviceEntity targetDevice = devices.first;
// debugPrint('📱 [AuthTcp] 准备切换至默认设备:${targetDevice.deviceName}');
_logger.logWithLevel('📱 [AuthTcp] 准备切换至默认设备:${targetDevice.deviceName}');
// 切换设备
final switchResult = await switchDeviceUseCase.deviceRepository.switchDevice("app",targetDevice.deviceName);
await switchResult.fold(
(failure) {
// debugPrint('❌ [AuthTcp] 切换设备失败:$failure');
_logger .logWithLevel('❌ [AuthTcp] 切换设备失败:$failure');
// _sendAuthPacket();
throw Exception('切换设备失败:$failure');
},
(success) {
// debugPrint('✅ [AuthTcp] 设备切换成功,服务端应开始推送数据');
_logger.logWithLevel('✅ [AuthTcp] 设备切换成功,服务端应开始推送数据');
},
);
},
);
},
);
} catch (e) {
// debugPrint('❌ [AuthTcp] 设备订阅流程异常:$e');
_logger.logWithLevel('❌ [AuthTcp] 设备订阅流程异常:$e');
rethrow;
}
} catch (e) {
// debugPrint('❌ [AuthTcp] 设备订阅流程异常:$e');
_logger.logWithLevel('❌ [AuthTcp] 设备订阅流程异常:$e');
rethrow;
}
}
void sendRawBytes(Uint8List bytes) {
@@ -439,9 +622,13 @@ _logger.logWithLevel('📱 [AuthTcp] 准备切换至默认设备:${targetDevic
_socket!.add(bytes);
}
Future<void> connectBySwitch({required String host, required int port, required String deviceName}) async {
isUserSwitch=true;
// debugPrint('🔌被动 [TCP] 开始连接:$host:$port'); // ✅ 必须看到这条
Future<void> connectBySwitch({
required String host,
required int port,
required String deviceName,
}) async {
isUserSwitch = true;
// debugPrint('🔌被动 [TCP] 开始连接:$host:$port'); // ✅ 必须看到这条
_logger.logWithLevel('🔌被动 [TCP] 开始连接:$host:$port');
_lastHost = host;
_lastPort = port;
@@ -459,54 +646,56 @@ _logger.logWithLevel('📱 [AuthTcp] 准备切换至默认设备:${targetDevic
);
// 🔥 关键修复:禁用Nagle算法,确保小包立即发送
_socket!.setOption(SocketOption.tcpNoDelay, true);
// debugPrint('✅ 被动[TCP] 连接成功!'); // ✅ 必须看到这条
// debugPrint('✅ 被动[TCP] 连接成功!'); // ✅ 必须看到这条
_logger.logWithLevel('✅ 被动[TCP] 连接成功!');
_socket!.listen((data) {
// debugPrint('📥 被动[TCP] 收到原始数据:${data.length} 字节, 内容:$data');
_logger.logWithLevel('📥 被动[TCP] 收到原始数据:${data.length} 字节, 内容:$data');
// var packets = _decoder.decode(data);
// debugPrint('📦 [TCP] 解码成功,包数量:${packets.length}');
//for (var packet in packets) {
// // 🔥若收到服务端心跳(cmd == 0xFF),立即回复一个心跳包
// if (packet.command == 0xFF) {
// debugPrint('收到服务端心跳,自动回复...');
// sendHeartbeat(); // 回复 AB AA FF AA AB
// }
// }
// _controller.add(packet);
// }
try {
var packets = _decoder.decode(data);
//debugPrint('📦 被动[TCP] 解码成功,包数量:${packets.length}');
_logger.logWithLevel('📦 被动[TCP] 解码成功,包数量:${packets.length}');
for (var packet in packets) {
if (!_controller.isClosed) {
_controller.add(packet);
//debugPrint('➡️被动 [TCP] 已分发 CMD: 0x${packet.command.toRadixString(16)}');
_logger.logWithLevel('➡️被动 [TCP] 已分发 CMD: 0x${packet.command.toRadixString(16)}');
_socket!.listen(
(data) {
// debugPrint('📥 被动[TCP] 收到原始数据:${data.length} 字节, 内容:$data');
_logger.logWithLevel('📥 被动[TCP] 收到原始数据:${data.length} 字节, 内容:$data');
// var packets = _decoder.decode(data);
// debugPrint('📦 [TCP] 解码成功,包数量:${packets.length}');
//for (var packet in packets) {
// // 🔥若收到服务端心跳(cmd == 0xFF),立即回复一个心跳包
// if (packet.command == 0xFF) {
// debugPrint('收到服务端心跳,自动回复...');
// sendHeartbeat(); // 回复 AB AA FF AA AB
// }
// }
// _controller.add(packet);
// }
try {
var packets = _decoder.decode(data);
//debugPrint('📦 被动[TCP] 解码成功,包数量:${packets.length}');
_logger.logWithLevel('📦 被动[TCP] 解码成功,包数量:${packets.length}');
for (var packet in packets) {
if (!_controller.isClosed) {
_controller.add(packet);
//debugPrint('➡️被动 [TCP] 已分发 CMD: 0x${packet.command.toRadixString(16)}');
_logger.logWithLevel(
'➡️被动 [TCP] 已分发 CMD: 0x${packet.command.toRadixString(16)}',
);
}
if (packet.command == 0xFF) {
//debugPrint('收到服务端心跳,自动回复...');
_logger.logWithLevel('✅ [TCP] 收到服务端心跳,自动回复...');
//
sendHeartbeat(); // 回复 AB AA FF AA AB
}
if (packet.command == 0x03) {
//debugPrint('⚠️ 收到认证响应:${packet.payload}');
_logger.logWithLevel('⚠️ 收到认证响应:${packet.payload}');
// 解析 payload 看是否有错误信息
}
}
if (packet.command == 0xFF) {
//debugPrint('收到服务端心跳,自动回复...');
_logger.logWithLevel('✅ [TCP] 收到服务端心跳,自动回复...');
//
sendHeartbeat(); // 回复 AB AA FF AA AB
}
if (packet.command == 0x03) {
//debugPrint('⚠️ 收到认证响应:${packet.payload}');
_logger.logWithLevel('⚠️ 收到认证响应:${packet.payload}');
// 解析 payload 看是否有错误信息
}
} catch (e, stackTrace) {
//debugPrint('❌ 被动[TCP] 解码数据时发生异常:$e\n$stackTrace'); // 🔥 捕获解码异常
_logger.logWithLevel('❌ 被动[TCP] 解码数据时发生异常:$e\n$stackTrace');
}
} catch (e, stackTrace) {
//debugPrint('❌ 被动[TCP] 解码数据时发生异常:$e\n$stackTrace'); // 🔥 捕获解码异常
_logger.logWithLevel('❌ 被动[TCP] 解码数据时发生异常:$e\n$stackTrace');
}
},
onDone: (){
},
onDone: () {
// TODO: 断线重连
// debugPrint('onDone❌ 被动[TCP] 连接已断开!');
// debugPrint('onDone❌ 被动[TCP] 连接已断开!');
_logger.logWithLevel('onDone❌ 被动[TCP] 连接已断开!');
if (!_isSwitching) {
_handleDisconnectBySwitch(deviceName);
@@ -519,13 +708,13 @@ _logger.logWithLevel('📱 [AuthTcp] 准备切换至默认设备:${targetDevic
}
},
onError: (e) {
// debugPrint('❌ 被动[TCP] 发生错误:$e');
// debugPrint('❌ 被动[TCP] 发生错误:$e');
_logger.logWithLevel('❌ 被动[TCP] 发生错误:$e');
_handleDisconnectBySwitch(deviceName); // 统一走重连逻辑,保护 Controller 不被关闭
},
);
// 开始认证tcp
await _sendAuthPacketBySwitch(deviceName);
await _sendAuthPacketBySwitch(deviceName);
} catch (e) {
rethrow; // 向上抛出连接异常
}
@@ -533,14 +722,14 @@ _logger.logWithLevel('📱 [AuthTcp] 准备切换至默认设备:${targetDevic
Future<void> _sendAuthPacketBySwitch(String deviceName) async {
if (_socket == null) return;
String? username= "";
String? token= "";
String? username = "";
String? token = "";
final user = await _userStorage.getUser();
deviceName= deviceName;
// debugPrint('tcp被动切换认证:$user');
deviceName = deviceName;
// debugPrint('tcp被动切换认证:$user');
_logger.logWithLevel('tcp被动切换认证:$user');
if (user == null || user.token == null) {
// debugPrint('❌ 被动[TCP] 认证失败:用户未登录或 Token 为空,无法发送认证包');
// debugPrint('❌ 被动[TCP] 认证失败:用户未登录或 Token 为空,无法发送认证包');
_logger.logWithLevel('❌ 被动[TCP] 认证失败:用户未登录或 Token 为空,无法发送认证包');
disconnect();
return; // 直接返回,不要发送无效包
@@ -548,7 +737,7 @@ _logger.logWithLevel('📱 [AuthTcp] 准备切换至默认设备:${targetDevic
username = user.username;
token = user.token;
final authString = '$username:app:$token';
// debugPrint('🔑被动[TCP] 认证字符串:$authString');
// debugPrint('🔑被动[TCP] 认证字符串:$authString');
_logger.logWithLevel('🔑被动[TCP] 认证字符串:$authString');
final authBytes = utf8.encode(authString);
@@ -574,22 +763,23 @@ _logger.logWithLevel('📱 [AuthTcp] 准备切换至默认设备:${targetDevic
try {
// 1. 获取 Either 结果
await switchDeviceUseCase.deviceRepository.switchDevice("app",deviceName);
await switchDeviceUseCase.deviceRepository.switchDevice(
"app",
deviceName,
);
} catch (e) {
//debugPrint('❌ 被动[AuthTcp] 设备订阅流程异常:$e');
_logger.logWithLevel('❌ 被动[AuthTcp] 设备订阅流程异常:$e');
rethrow;
}
}
void _handleDisconnectBySwitch( String deviceName) {
void _handleDisconnectBySwitch(String deviceName) {
stopHeartbeat();
_socket = null;
// 注意:这里不要关闭 _controller!否则监听者会丢失数据流
// _controller?.close();
_scheduleReconnectBySwitch(deviceName);
}
}

View File

@@ -0,0 +1,52 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
enum TcpConnectionStatus {
disconnected, // 未连接
connecting, // 连接中
connected, // 已连接
error, // 连接错误
}
class TcpStatusState {
final TcpConnectionStatus status;
final String? errorMessage;
const TcpStatusState({
this.status = TcpConnectionStatus.disconnected,
this.errorMessage,
});
TcpStatusState copyWith({
TcpConnectionStatus? status,
String? errorMessage,
}) {
return TcpStatusState(
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
);
}
}
class TcpStatusCubit extends Cubit<TcpStatusState> {
TcpStatusCubit() : super(const TcpStatusState());
void setConnecting() {
emit(const TcpStatusState(status: TcpConnectionStatus.connecting));
}
void setConnected() {
emit(const TcpStatusState(status: TcpConnectionStatus.connected));
}
void setDisconnected() {
emit(const TcpStatusState(status: TcpConnectionStatus.disconnected));
}
void setError(String message) {
emit(TcpStatusState(
status: TcpConnectionStatus.error,
errorMessage: message,
));
}
}

View File

@@ -60,48 +60,48 @@ class AuthTcpDatasourceImpl implements AuthTcpDatasource {
debugPrint('✅ sendAuthPacket[AuthTcp] 认证包已发送');
// 获取用户设备列表
try {
// 1. 获取 Either 结果
final eitherResult = await getUserDeviceUseCase.repository.getUserDevice(username);
// 2. 使用 fold 解包 Either
// left: 处理错误情况 (DeviceFailure)
// right: 处理成功情况 (List<DeviceEntity>)
await eitherResult.fold(
(failure) {
// 处理失败:打印日志或抛出异常
debugPrint('❌ sendAuthPacket [AuthTcp] 获取设备列表失败:$failure');
throw Exception('获取设备列表失败:$failure');
},
(devices) async {
// 处理成功:devices 现在是真正的 List<DeviceEntity>
if (devices.isEmpty) {
debugPrint('⚠️ sendAuthPacket[AuthTcp] 当前用户无可用设备,跳过切换步骤');
return;
}
// 取第一个设备
final DeviceEntity targetDevice = devices.first;
debugPrint('📱 sendAuthPacket[AuthTcp] 准备切换至默认设备:${targetDevice.deviceName}');
// 切换设备 (同样,如果 switchDeviceUseCase 也返回 Either,也需要 fold 处理)
final switchResult = await switchDeviceUseCase.deviceRepository.switchDevice("app",targetDevice.deviceName);
await switchResult.fold(
(failure) {
debugPrint('❌ sendAuthPacket[AuthTcp] 切换设备失败:$failure');
throw Exception('切换设备失败:$failure');
},
(success) {
debugPrint('✅ sendAuthPacket[AuthTcp] 设备切换成功,服务端应开始推送数据');
},
);
},
);
} catch (e) {
debugPrint('❌ sendAuthPacket[AuthTcp] 设备订阅流程异常:$e');
rethrow;
}
// try {
// // 1. 获取 Either 结果
// final eitherResult = await getUserDeviceUseCase.repository.getUserDevice(username);
//
// // 2. 使用 fold 解包 Either
// // left: 处理错误情况 (DeviceFailure)
// // right: 处理成功情况 (List<DeviceEntity>)
// await eitherResult.fold(
// (failure) {
// // 处理失败:打印日志或抛出异常
// debugPrint('❌ sendAuthPacket [AuthTcp] 获取设备列表失败:$failure');
// throw Exception('获取设备列表失败:$failure');
// },
// (devices) async {
// // 处理成功:devices 现在是真正的 List<DeviceEntity>
// if (devices.isEmpty) {
// debugPrint('⚠️ sendAuthPacket[AuthTcp] 当前用户无可用设备,跳过切换步骤');
// return;
// }
//
// // 取第一个设备
// final DeviceEntity targetDevice = devices.first;
// debugPrint('📱 sendAuthPacket[AuthTcp] 准备切换至默认设备:${targetDevice.deviceName}');
//
// // 切换设备 (同样,如果 switchDeviceUseCase 也返回 Either,也需要 fold 处理)
// final switchResult = await switchDeviceUseCase.deviceRepository.switchDevice("app",targetDevice.deviceName);
//
// await switchResult.fold(
// (failure) {
// debugPrint('❌ sendAuthPacket[AuthTcp] 切换设备失败:$failure');
// throw Exception('切换设备失败:$failure');
// },
// (success) {
// debugPrint('✅ sendAuthPacket[AuthTcp] 设备切换成功,服务端应开始推送数据');
// },
// );
// },
// );
// } catch (e) {
// debugPrint('❌ sendAuthPacket[AuthTcp] 设备订阅流程异常:$e');
// rethrow;
// }
}
}

View File

@@ -38,7 +38,13 @@ class AuthCubit extends Cubit<AuthState> {
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期
AuthCubit(this.storage, this.tcp, this.appCubit, this.dispatcher, this._authTcpDatasource) : super(AuthInitial()) {
AuthCubit(
this.storage,
this.tcp,
this.appCubit,
this.dispatcher,
this._authTcpDatasource,
) : super(AuthInitial()) {
// Cubit 一启动就开始监听 TCP 的“自动逻辑”
_listenToAuthResponse();
}
@@ -49,21 +55,20 @@ class AuthCubit extends Cubit<AuthState> {
try {
final user = await storage.getUser();
logger.logWithLevel(
'启动时检查本地缓存',
level: 'INFO',
data: {'user': user != null ? '找到用户: ${user.username}' : '未找到用户'}
'启动时检查本地缓存',
level: 'INFO',
data: {'user': user != null ? '找到用户: ${user.username}' : '未找到用户'},
);
if (user != null) {
// await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
// await _authTcpDatasource.sendAuthPacket();//包括发送认证包和获取列表和切换函数
// tcp.startHeartbeat(interval: const Duration(seconds: 4));
// 🔥 冷启动时重新初始化 TCP 连接
await tcp.initializeTcp(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
// 2. 同步全局 App 状态
appCubit.setAuth(user);
// 3. 进入已登录状态
emit(AuthAuthenticated(user));
logger.logWithLevel('✅ [AUTH] 应用启动 - 已恢复登录状态', level: 'INFO');
logger.logWithLevel('✅ [AUTH] 应用启动 - 已恢复登录状态并建立TCP连接', level: 'INFO');
} else {
logger.logWithLevel('⚠️ [AUTH] 应用启动 - 无本地缓存,进入未登录状态', level: 'INFO');
emit(AuthUnauthenticated());
@@ -77,9 +82,10 @@ class AuthCubit extends Cubit<AuthState> {
/// 当 HTTP 登录/注册成功后调用
Future<void> loginSuccess(UserEntity user) async {
await storage.saveUser(user);
// await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
// await _authTcpDatasource.sendAuthPacket(); //包括发送认证包和获取列表和切换函数
//tcp.startHeartbeat(interval: const Duration(seconds: 4)); //启动心跳
// 🔥 使用封装的TCP初始化方法:连接 + 认证 + 心跳
await tcp.initializeTcp(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
appCubit.setAuth(user);
emit(AuthAuthenticated(user));
}
@@ -92,7 +98,7 @@ class AuthCubit extends Cubit<AuthState> {
_clearAllBusinessState();
await storage.deleteUser();
tcp.disconnect();
tcp.disconnect(); // 断开后会自动重连
appCubit.clearAuth();
emit(AuthUnauthenticated());
}
@@ -129,65 +135,78 @@ class AuthCubit extends Cubit<AuthState> {
/// TCP 指令监听
void _listenToAuthResponse() {
_logger.logWithLevel('[AUTH] 监听 TCP 0x12 指令...', level: 'DEBUG');
debugPrint('>>> [AUTH] _listenToAuthResponse() 被调用,开始监听 0x12 指令');
_kickOutSub?.cancel(); // 防止重复监听
// 直接监听原始数据包,自己处理 JSON 解析(去掉 CRC 字节)
_kickOutSub = dispatcher.onCommand(0x12).listen((packet) {
_logger.logWithLevel('[AUTH] 获取原始包:$packet', level: 'DEBUG');
_logger.logWithLevel('>>> [AUTH] 收到 0x12 原始包,payload 长度=${packet.payload.length}, 内容=${packet.payload}', level: 'DEBUG');
debugPrint(
'>>> [AUTH] 收到 0x12 原始包,payload 长度=${packet.payload.length}, 内容=${packet.payload}',
);
try {
// 🔥 关键:手动去掉最后 2 个 CRC 字节
String jsonString;
if (packet.payload.length > 2) {
jsonString = utf8.decode(packet.payload.sublist(0, packet.payload.length - 2));
jsonString = utf8.decode(
packet.payload.sublist(0, packet.payload.length - 2),
);
} else {
jsonString = utf8.decode(packet.payload);
}
_logger.logWithLevel('[AUTH] 获取 JSON: $jsonString', level: 'DEBUG');
debugPrint('>>> [AUTH] 获取 JSON: "$jsonString"');
final jsonMap = jsonDecode(jsonString);
_logger.logWithLevel('[AUTH] 获取 JSON Map: $jsonMap', level: 'DEBUG');
debugPrint('>>> [AUTH] 获取 JSON Map: $jsonMap');
final respond = jsonMap['respond'] ?? '';
debugPrint('>>> [AUTH] respond 字段值: "$respond"');
_logger.logWithLevel(
'>>> [AUTH] respond 字段值: "$respond"',
shouldLog: true,
);
debugPrint('>>> [AUTH] respond 类型: ${respond.runtimeType}');
debugPrint(
'>>> [AUTH] respond == "have_logged_in": ${respond == "have_logged_in"}',
);
if (respond == 'have_logged_in') {
_logger.logWithLevel('[AUTH] ⚠️ 检测到异地登录 (respond=have_logged_in),开始退出...', level: 'WARN');
debugPrint('>>> [AUTH] ⚠️ 检测到异地登录 (respond=have_logged_in),开始退出...');
_logger.logWithLevel(
'⚠️ [AUTH] 检测到异地登录 (respond=have_logged_in),开始退出...',
level: 'INFO',
);
logout();
} else {
_logger.logWithLevel('[AUTH] ⚠️ 检测到异地登录 (respond=$respond),开始退出...', level: 'WARN');
debugPrint('>>> [AUTH] ℹ️ 收到 0x12 消息,respond="$respond",不处理');
}
} catch (e) {
_logger.logWithLevel('[AUTH] ❌ JSON 解析失败:$e', level: 'ERROR');
debugPrint('>>> [AUTH] ❌ JSON 解析失败:$e');
}
});
_logger.logWithLevel('[AUTH] 监听 TCP 0x12 指令...', level: 'DEBUG');
_logger.logWithLevel('[AUTH] 监听 TCP 0x12 指令...', level: 'INFO');
}
@override
Future<void> close() {
_kickOutSub?.cancel(); // 销毁 Cubit 时关闭监听
return super.close();
}
/// 🔥 息屏/后台后恢复到前台时的重连方法
Future<void> reconnectAfterResume() async {
_logger.logWithLevel('[AUTH] 检测到应用恢复到前台,检查 TCP 连接状态...', level: 'DEBUG');
_logger.logWithLevel('[AUTH] 检测到应用恢复到前台,检查 TCP 连接状态...', level: 'INFO');
// 如果 TCP 未连接,则执行重连
if (!tcp.isConnected) {
_logger.logWithLevel('[AUTH] TCP 未连接,开始重连...',
level: 'DEBUG');
_logger.logWithLevel('[AUTH] TCP 未连接,开始重连...', level: 'INFO');
try {
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
tcp.startHeartbeat(interval: const Duration(seconds: 4));
_logger.logWithLevel('[AUTH] TCP 重连成功!', level: 'DEBUG');
_logger.logWithLevel('[AUTH] TCP 重连成功!', level: 'INFO');
} catch (e) {
_logger.logWithLevel('[AUTH] TCP 重连失败:$e', level: 'ERROR');
_logger.logWithLevel('[AUTH] TCP 重连失败:$e', level: 'ERROR');
}
} else {
_logger.logWithLevel('[AUTH] TCP 已连接,无需重连', level: 'DEBUG');
_logger.logWithLevel('[AUTH] TCP 已连接,无需重连', level: 'INFO');
// 可选:发送一个心跳包确认连接有效
tcp.sendHeartbeat();
}

View File

@@ -20,15 +20,23 @@ class LoginCubit extends Cubit<LoginState> {
Future<void> login(String username, String password, sourceType) async {
emit(LoginLoading());
try {
final result = await loginUseCase.call(LoginParams(username, password, sourceType));
final result = await loginUseCase.call(
LoginParams(username, password, sourceType),
);
// final user = sl<AppUserCubit>().state.user;
// final devicesCubit = sl<DevicesCubit>();
// if (user != null) {
// devicesCubit.fetchAllDevices(user.username);
// }
result.fold((failure) => emit(LoginFailure(failure.message)), (user) {
// 先更新全局用户状态(确保首页能获取到 token)
authCubit.appCubit.setAuth(user);
// 发出登录成功状态(触发页面跳转)
emit(LoginSuccess(user));
authCubit.loginSuccess(user);
// 后台异步执行 TCP 连接等初始化操作(不阻塞登录流程)
authCubit.loginSuccess(user).catchError((e) {
print('TCP 初始化失败: $e');
});
});
} catch (e) {
emit(LoginFailure(e.toString()));

View File

@@ -122,7 +122,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource {
final responseData = response.data;
logger.logWithLevel('API 响应成功', level: 'INFO', data: {'statusCode': response.statusCode, 'data': response.data});
if (responseData['code'] != 200 || responseData['data'] != true) {
if (responseData['code'] != 200) {
logger.logWithLevel('API 响应失败', level: 'ERROR', data: {'statusCode': response.statusCode, 'data': response.data});
throw Exception(responseData['msg'] ?? '业务异常');
}

View File

@@ -4,15 +4,21 @@ class DeviceAddPathPointModel {
DeviceAddPathPointModel({required this.latitude, required this.longitude});
Map<String, dynamic> toJson() => {'lat': latitude.toString(), 'lon': longitude.toString()};
Map<String, dynamic> toJson() => {
'lat': latitude.toString(),
'lng': longitude.toString(),
};
factory DeviceAddPathPointModel.fromJson(Map<String, dynamic> json) {
return DeviceAddPathPointModel(
latitude: double.parse((json['lat'] ?? json['latitude']).toString()),
longitude: double.parse((json['lon'] ?? json['longitude']).toString()),
longitude: double.parse(
(json['lng'] ?? json['lon'] ?? json['longitude']).toString(),
),
);
}
@override
String toString() => '{lat: ${latitude.toString()}, lon: ${longitude.toString()}}';
String toString() =>
'{lat: ${latitude.toString()}, lon: ${longitude.toString()}}';
}

View File

@@ -4,7 +4,7 @@ class ReferencePoint {
ReferencePoint({required this.lat, required this.lon});
Map<String, dynamic> toJson() => {'lat': lat, 'lon': lon};
Map<String, dynamic> toJson() => {'lat': lat, 'lng': lon};
}
class OuterBoundary {
@@ -13,7 +13,10 @@ class OuterBoundary {
OuterBoundary({required this.position, required this.sideWidth});
Map<String, dynamic> toJson() => {'position': position.map((p) => p.toJson()).toList(), 'sideWidth': sideWidth};
Map<String, dynamic> toJson() => {
'position': position.map((p) => p.toJson()).toList(),
'sideWidth': sideWidth,
};
}
class HoleBoundary {
@@ -36,13 +39,17 @@ class HoleBoundary {
// 一维数组,包装成二维数组 [[p1,p2,p3]]
return [input];
} else {
throw ArgumentError('position 必须是 List<Position> 或 List<List<Position>> 类型');
throw ArgumentError(
'position 必须是 List<Position> 或 List<List<Position>> 类型',
);
}
}
// 核心修改:toJson 输出二维数组格式
Map<String, dynamic> toJson() => {
'position': position.map((innerList) => innerList.map((p) => p.toJson()).toList()).toList(), // 二维数组:[[{lat,lon}], ...]
'position': position
.map((innerList) => innerList.map((p) => p.toJson()).toList())
.toList(), // 二维数组:[[{lat,lon}], ...]
'sideWidth': sideWidth,
};
@@ -53,7 +60,9 @@ class HoleBoundary {
// 2. 遍历外层数组,将每个内层数组转为 List<Position>
final List<List<Position>> position = outerList.map((innerList) {
// 3. 处理内层数组,转为 Position 对象列表
return (innerList as List).map((p) => Position.fromJson(p as Map<String, dynamic>)).toList();
return (innerList as List)
.map((p) => Position.fromJson(p as Map<String, dynamic>))
.toList();
}).toList();
// 4. 提取sideWidth(确保数字类型)
@@ -72,9 +81,10 @@ class Position {
factory Position.fromJson(Map<String, dynamic> json) {
// 安全解析经纬度,避免空值/非数字
final double lat = (json['lat'] as num?)?.toDouble() ?? 0.0;
final double lon = (json['lon'] as num?)?.toDouble() ?? 0.0;
final double lon =
((json['lng'] ?? json['lon']) as num?)?.toDouble() ?? 0.0;
return Position(lat: lat, lon: lon);
}
Map<String, dynamic> toJson() => {'lat': lat, 'lon': lon};
Map<String, dynamic> toJson() => {'lat': lat, 'lng': lon};
}

View File

@@ -75,25 +75,25 @@ class RunningStatusEntity extends Equatable {
logger.logWithLevel(
'字段总数: ${fields.length}',
level: 'DEBUG',
shouldLog: true,
shouldLog: false,
);
if (fields.length > 14) {
logger.logWithLevel(
'qual原始值(fields[14]): "${fields[14]}"',
level: 'DEBUG',
shouldLog: true,
shouldLog: false,
);
logger.logWithLevel(
'satelliteCnt(fields[13]): "${fields[13]}"',
level: 'DEBUG',
shouldLog: true,
shouldLog: false,
);
logger.logWithLevel(
/* logger.logWithLevel(
'headingStatus(fields[15]): "${fields[15]}"',
level: 'DEBUG',
shouldLog: true,
);
);*/
}
// 确保至少有 24 个字段,不足的用默认值填充

View File

@@ -73,10 +73,10 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
.map((p) {
try {
final result = utf8.decode(p.payload, allowMalformed: true);
debugPrint('✅ [DeviceStatusBloc] 收到0x02数据: $result');
// debugPrint('✅ [DeviceStatusBloc] 收到0x02数据: $result');
return result;
} catch (e) {
debugPrint('❌ [DeviceStatusBloc] 解码失败: $e');
// debugPrint('❌ [DeviceStatusBloc] 解码失败: $e');
return '';
}
})
@@ -105,7 +105,7 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
final gps = GPSEntity(status.latitude, status.longitude);
//debugPrint('✅ [DeviceStatusBloc] 直接解析成功,更新状态:Lat=${gps.latitude}, Lng=${gps.longitude}');
_logger.log('✅ [DeviceStatusBloc] 直接解析成功,更新状态');
// _logger.log('✅ [DeviceStatusBloc] 直接解析成功,更新状态');
// 🔥 关键修复:BLoC有Equatable去重机制,必须创建新对象才能触发UI更新
if (!isClosed) {
@@ -137,12 +137,12 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
obstacleFlag: status.obstacleFlag,
);
final newGps = GPSEntity(status.latitude, status.longitude);
debugPrint('📤 [DeviceStatusBloc] emit DeviceStatusUpdated - 电压:${status.voltage}, 电量:${status.battery}, 模式:${status.controlMode}');
// debugPrint('📤 [DeviceStatusBloc] emit DeviceStatusUpdated - 电压:${status.voltage}, 电量:${status.battery}, 模式:${status.controlMode}');
emit(DeviceStatusUpdated(newStatus, newGps));
}
} catch (e, stack) {
//debugPrint('❌ [DeviceStatusBloc] 直接解析异常:$e\n$stack');
_logger.log('❌ [DeviceStatusBloc] 直接解析异常:$e');
// _logger.log('❌ [DeviceStatusBloc] 直接解析异常:$e');
if (!isClosed) {
emit(DeviceStatusError('解析失败:$e'));
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:maibu_satabot_v2/components/tcp_status_indicator.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/commonFn.dart';
@@ -55,7 +56,14 @@ class ImmersionHeader extends StatelessWidget {
// 机器大图
Transform.translate(
offset: const Offset(30, 80), // 正数向右移动(例如 20 像素),负数向左
child: Center(child: Image.asset('assets/images/car.png', width: 330, height: 190, fit: BoxFit.contain)),
child: Center(
child: Image.asset(
'assets/images/car.png',
width: 330,
height: 190,
fit: BoxFit.contain,
),
),
),
// 顶部状态信息
@@ -74,12 +82,28 @@ class ImmersionHeader extends StatelessWidget {
mainAxisSize: MainAxisSize.min, // 尽可能收缩高度
children: [
// 设备名称和电量
Text(_formatName(device.displayName), style: GoogleFonts.roboto(fontSize: 22, fontWeight: FontWeight.w900)),
Text(
_formatName(device.displayName),
style: GoogleFonts.roboto(
fontSize: 22,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Row(
children: [
Text('${device.onlineStatus == 1 ? "100" : "--"} %', style: GoogleFonts.roboto(fontSize: 24, fontWeight: FontWeight.w900)),
const Icon(Icons.chevron_right, size: 20, color: Colors.grey),
Text(
'${device.onlineStatus == 1 ? "100" : "--"} %',
style: GoogleFonts.roboto(
fontSize: 24,
fontWeight: FontWeight.w900,
),
),
const Icon(
Icons.chevron_right,
size: 20,
color: Colors.grey,
),
],
),
@@ -103,14 +127,24 @@ class ImmersionHeader extends StatelessWidget {
_buildCircleIcon(Icons.sync, () {
// 1. 触发 Cubit 请求最新设备列表
// 假设你的 username 存储在 AuthCubit 或类似的全局状态中
final username = context.read<AppUserCubit>().state.user?.username ?? "";
final username =
context.read<AppUserCubit>().state.user?.username ??
"";
context.read<DevicesCubit>().fetchAllDevices(username);
// 2. 弹出窗口(窗口内部会根据状态显示转圈或列表)
_showDeviceSwitcher(context);
}),
const SizedBox(height: 20), // 这里你改大的间距,只会让两个图标拉开
_buildCircleIcon(Icons.bluetooth, null, isAccent: true),
// TCP状态指示灯 - 暂时关闭
// Container(
// padding: const EdgeInsets.all(8),
// decoration: BoxDecoration(
// shape: BoxShape.circle,
// color: Colors.blue.withOpacity(0.1),
// ),
// child: const TcpStatusIndicator(size: 14),
// ),
],
),
],
@@ -129,15 +163,22 @@ class ImmersionHeader extends StatelessWidget {
// 在 offWhite 背景下,标签用纯白色会显得更精致
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 4)],
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 4),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(radius: 3, backgroundColor: isOnline ? Colors.green : Colors.grey),
CircleAvatar(
radius: 3,
backgroundColor: isOnline ? Colors.green : Colors.grey,
),
const SizedBox(width: 6),
Text(
isOnline ? AppLocalizations.of(context).translate('home.device_online') : AppLocalizations.of(context).translate('home.device_offline'),
isOnline
? AppLocalizations.of(context).translate('home.device_online')
: AppLocalizations.of(context).translate('home.device_offline'),
style: const TextStyle(fontSize: 12, color: Colors.black54),
overflow: TextOverflow.ellipsis,
),
@@ -151,7 +192,10 @@ class ImmersionHeader extends StatelessWidget {
return Container(
width: 120,
height: 6,
decoration: BoxDecoration(color: Colors.blueAccent, borderRadius: BorderRadius.circular(5)),
decoration: BoxDecoration(
color: Colors.blueAccent,
borderRadius: BorderRadius.circular(5),
),
);
}
@@ -176,7 +220,11 @@ class ImmersionHeader extends StatelessWidget {
),
],
),
child: Icon(icon, size: 30, color: isAccent ? Colors.blue : Colors.black54),
child: Icon(
icon,
size: 30,
color: isAccent ? Colors.blue : Colors.black54,
),
),
);
}
@@ -202,15 +250,27 @@ class ImmersionHeader extends StatelessWidget {
margin: const EdgeInsets.symmetric(vertical: 12),
width: 36,
height: 5,
decoration: BoxDecoration(color: Colors.grey[300], borderRadius: BorderRadius.circular(10)),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(10),
),
),
Text(
AppLocalizations.of(modalContext).translate('home.switch_device'),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
AppLocalizations.of(
modalContext,
).translate('home.switch_device'),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 10),
Container(height: 0.5, margin: const EdgeInsets.symmetric(horizontal: 20), color: Colors.grey.withOpacity(0.1)),
Container(
height: 0.5,
margin: const EdgeInsets.symmetric(horizontal: 20),
color: Colors.grey.withOpacity(0.1),
),
// 💡 动态内容区
Expanded(
@@ -225,7 +285,12 @@ class ImmersionHeader extends StatelessWidget {
children: [
const CircularProgressIndicator(color: Colors.blue),
const SizedBox(height: 16),
Text(AppLocalizations.of(context).translate('home.loading'), style: TextStyle(color: Colors.grey[600])),
Text(
AppLocalizations.of(
context,
).translate('home.loading'),
style: TextStyle(color: Colors.grey[600]),
),
],
),
);
@@ -237,9 +302,21 @@ class ImmersionHeader extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.description_outlined, size: 60, color: Colors.grey),
const Icon(
Icons.description_outlined,
size: 60,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(AppLocalizations.of(context).translate('home.no_devices'), style: const TextStyle(fontSize: 16, color: Colors.grey)),
Text(
AppLocalizations.of(
context,
).translate('home.no_devices'),
style: const TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
);
@@ -247,12 +324,21 @@ class ImmersionHeader extends StatelessWidget {
return ListView.builder(
// 💡 底部留出安全距离,防止最后一条滚不上来
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 20, top: 10),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 20,
top: 10,
),
itemCount: state.devices.length,
itemBuilder: (context, index) {
// 传入当前选中的 ID 方便做 UI 区分
final isSelected = state.selectedDevice?.deviceName == state.devices[index].deviceName;
return _buildDeviceItem(state.devices[index], context, isSelected);
final isSelected =
state.selectedDevice?.deviceName ==
state.devices[index].deviceName;
return _buildDeviceItem(
state.devices[index],
context,
isSelected,
);
},
);
},
@@ -265,7 +351,11 @@ class ImmersionHeader extends StatelessWidget {
);
}
Widget _buildDeviceItem(DeviceEntity device, BuildContext context, bool isSelected) {
Widget _buildDeviceItem(
DeviceEntity device,
BuildContext context,
bool isSelected,
) {
return GestureDetector(
onTap: () {
print("跳转到详情页: ${device.deviceAlias}");
@@ -277,14 +367,29 @@ class ImmersionHeader extends StatelessWidget {
decoration: BoxDecoration(
color: Colors.white,
// 💡 如果是当前选中设备,边框颜色略深
border: Border.all(color: isSelected ? Colors.blue.withOpacity(0.5) : Colors.grey.withOpacity(0.3), width: isSelected ? 1.5 : 1),
border: Border.all(
color: isSelected
? Colors.blue.withOpacity(0.5)
: Colors.grey.withOpacity(0.3),
width: isSelected ? 1.5 : 1,
),
borderRadius: BorderRadius.circular(12),
boxShadow: [if (isSelected) BoxShadow(color: Colors.blue.withOpacity(0.05), blurRadius: 4)],
boxShadow: [
if (isSelected)
BoxShadow(color: Colors.blue.withOpacity(0.05), blurRadius: 4),
],
),
child: Row(
children: [
// 设备图片
Transform.translate(offset: const Offset(10, 0), child: Image.asset('assets/images/car.png', width: 100, height: 80)),
Transform.translate(
offset: const Offset(10, 0),
child: Image.asset(
'assets/images/car.png',
width: 100,
height: 80,
),
),
const SizedBox(width: 12),
// 设备信息
Expanded(
@@ -293,11 +398,19 @@ class ImmersionHeader extends StatelessWidget {
children: [
Row(
children: [
Icon(Icons.circle, color: device.isOnline ? Colors.green : Colors.grey, size: 12),
Icon(
Icons.circle,
color: device.isOnline ? Colors.green : Colors.grey,
size: 12,
),
const SizedBox(width: 4),
Flexible(
child: Text(
(device.deviceAlias?.trim() ?? '').isEmpty ? AppLocalizations.of(context).translate('home.unknown_device') : device.deviceAlias!,
(device.deviceAlias?.trim() ?? '').isEmpty
? AppLocalizations.of(
context,
).translate('home.unknown_device')
: device.deviceAlias!,
style: const TextStyle(fontWeight: FontWeight.bold),
// 关键属性:处理文本溢出
maxLines: 2, // 最多显示2行
@@ -309,7 +422,9 @@ class ImmersionHeader extends StatelessWidget {
),
const SizedBox(height: 6),
Text(
AppLocalizations.of(context).translate('home.click_to_view'),
AppLocalizations.of(
context,
).translate('home.click_to_view'),
style: const TextStyle(color: Colors.grey, fontSize: 12),
overflow: TextOverflow.ellipsis,
),
@@ -324,7 +439,9 @@ class ImmersionHeader extends StatelessWidget {
? null
: () async {
// 💡 执行切换逻辑
await context.read<DevicesCubit>().switchDevice(device);
await context.read<DevicesCubit>().switchDevice(
device,
);
await LocationUtils.clearAllPlotCache();
// 切换成功后,UI 会自动更新(因为 BlocBuilder 在监听),我们可以关闭弹窗
@@ -332,7 +449,9 @@ class ImmersionHeader extends StatelessWidget {
Navigator.pop(context);
}
},
text: isSelected ? AppLocalizations.of(context).translate('home.in_use') : AppLocalizations.of(context).translate('home.switch'),
text: isSelected
? AppLocalizations.of(context).translate('home.in_use')
: AppLocalizations.of(context).translate('home.switch'),
width: 90,
height: 30,
fontSize: 14,
@@ -342,7 +461,10 @@ class ImmersionHeader extends StatelessWidget {
const SizedBox(height: 8),
CCPrimaryButton(
onPressed: () {
context.push(RoutePaths.machineDetails, extra: device); // 把当前点击的这个设备对象传过去
context.push(
RoutePaths.machineDetails,
extra: device,
); // 把当前点击的这个设备对象传过去
print("通过按钮进入详情,设备名称:$device");
},

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maibu_satabot_v2/components/tcp_status_indicator.dart';
import 'package:maibu_satabot_v2/features/ai/presentation/pages/ai_page.dart';
import 'package:maibu_satabot_v2/features/home/presentation/pages/home_page.dart';
import 'package:maibu_satabot_v2/features/my/presentation/pages/my_page.dart';
@@ -53,9 +54,39 @@ class _CustomMainContainerState extends State<CustomMainContainer>
}
return Scaffold(
body: IndexedStack(
index: currentIndex,
children: _buildPages(enabledTabs),
body: Stack(
children: [
IndexedStack(
index: currentIndex,
children: _buildPages(enabledTabs),
),
// TCP状态指示灯 - 右上角,带白色背景确保可见
Positioned(
top: 40,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5), // 透明灰色背景
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TcpStatusIndicator(size: 12),
const SizedBox(width: 6),
const Text(
'TCP',
style: TextStyle(fontSize: 12, color: Colors.white),
),
],
),
),
),
],
),
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,

View File

@@ -27,19 +27,23 @@ class RemoteHttpDatasource {
}*/
Future<bool> requestRemoteControlViaHttp( String deviceId, String currentDeviceId) async {
/// 🔥 返回完整权限信息: {hasPermission, owner}
Future<Map<String, dynamic>> requestRemoteControlViaHttp(String deviceName, String platform) async {
print(deviceName+"@@@");
final user = await _userStorage.getUser();
if (user == null) {
debugPrint('❌ [RemoteHttp] 用户未登录,无法请求远程控制权限');
_logger.log('❌ [RemoteHttp] 用户未登录,无法请求远程控制权限');
return false;
return {'hasPermission': false, 'owner': null};
}
final token = user.token;
debugPrint('🔑 [RemoteHttp] 开始请求远程控制权限 - deviceId: $deviceId, platform: app');
_logger.log('🔑 [RemoteHttp] 开始请求远程控制权限 - deviceId: $deviceId, platform: app');
debugPrint('🔑 [RemoteHttp] 开始请求远程控制权限 - deviceName: $deviceName, platform: $platform');
_logger.log('🔑 [RemoteHttp] 开始请求远程控制权限 - deviceName: $deviceName, platform: $platform');
try {
debugPrint('📡 [RemoteHttp] 正在发送 HTTP POST 请求到 /forward/device/remoteControl');
debugPrint('📡 [RemoteHttp] 请求参数: deviceId=$deviceName, platform=$platform');
final response = await _dio.post(
'/forward/device/remoteControl',
options: Options(
@@ -48,7 +52,8 @@ class RemoteHttpDatasource {
'Authorization': 'Bearer ${token}',
},
),
data: {'deviceId': deviceId, 'platform': "app"});
data: {'deviceId': deviceName, 'platform': platform});
debugPrint('📥 [RemoteHttp] HTTP 请求已发送,等待响应...');
debugPrint("📊 [RemoteHttp] HTTP响应状态码: ${response.statusCode}");
_logger.log("📊 [RemoteHttp] HTTP响应状态码: ${response.statusCode}");
@@ -57,24 +62,25 @@ class RemoteHttpDatasource {
debugPrint("📊 [RemoteHttp] 完整响应数据:$responseData");
_logger.log("📊 [RemoteHttp] 完整响应数据:$responseData");
// 🔥 关键:先获取响应的 data 字段,再获取 remoteControl
// 🔥 关键:先获取响应的 data 字段,再获取 remoteControl 和 owner
final dataField = responseData['data'] as Map<String, dynamic>?;
if (dataField != null) {
final bool hasRemoteControl = dataField['remoteControl'] as bool? ?? false;
final String? owner = dataField['owner'] as String?;
debugPrint("✅ [RemoteHttp] 解析成功 - remoteControl=$hasRemoteControl");
_logger.log("✅ [RemoteHttp] 解析成功 - remoteControl=$hasRemoteControl");
// 🔥 直接返回 remoteControl 的布尔值
return hasRemoteControl;
debugPrint("✅ [RemoteHttp] 解析成功 - remoteControl=$hasRemoteControl, owner=$owner");
_logger.log("✅ [RemoteHttp] 解析成功 - remoteControl=$hasRemoteControl, owner=$owner");
// 🔥 返回完整权限信息
return {'hasPermission': hasRemoteControl, 'owner': owner};
} else {
debugPrint("❌ [RemoteHttp] 缺少 data 字段,响应结构异常");
_logger.log("❌ [RemoteHttp] 缺少 data 字段,响应结构异常");
return false;
return {'hasPermission': false, 'owner': null};
}
} catch (e) {
debugPrint('❌ [RemoteHttp] 请求远程控制权限失败:$e');
_logger.log('❌ [RemoteHttp] 请求远程控制权限失败:$e');
return false;
return {'hasPermission': false, 'owner': null};
}
}

View File

@@ -3,7 +3,9 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../../../core/logging/i_logger_service.dart';
import '../../../../core/network/protocol_decoder.dart';
import '../../../../core/network/tcp/tcp_client.dart';
import '../../../../core/protocol/machine_protocol_constants.dart';
@@ -14,6 +16,7 @@ import '../../../../core/storage/user_storage.dart';
class RemoteTcpDatasource {
final TcpClient _tcpClient;
final UserStorage _userStorage;
final ILoggerService _logger = GetIt.I<ILoggerService>();
RemoteTcpDatasource(this._tcpClient, this._userStorage);
@@ -33,7 +36,9 @@ class RemoteTcpDatasource {
// 获取用户信息
final user = await _userStorage.getUser();
if (user == null || user.token == null) {
debugPrint('❌ sendSwitchControlRequest[TCP] 认证失败:用户未登录或 Token 为空,无法发送认证包');
debugPrint(
'❌ sendSwitchControlRequest[TCP] 认证失败:用户未登录或 Token 为空,无法发送认证包',
);
throw Exception('用户未登录或 Token 无效');
}
@@ -66,12 +71,16 @@ class RemoteTcpDatasource {
// 构造完整的协议包
final packet = buildSwitchControlPacket(jsonBytes);
debugPrint('sendSwitchControlRequest[TCP] 完整数据包:${packet.map((b) => '0x${b.toRadixString(16)}').join(' ')}');
debugPrint(
'sendSwitchControlRequest[TCP] 完整数据包:${packet.map((b) => '0x${b.toRadixString(16)}').join(' ')}',
);
// 发送数据包
_tcpClient.sendnew(packet);
debugPrint('✅ sendSwitchControlRequest[TCP] 已发送 switch_control 权限请求 (0x${MachineProtocolConstants.cmdGetAuth.toRadixString(16)})');
debugPrint(
'✅ sendSwitchControlRequest[TCP] 已发送 switch_control 权限请求 (0x${MachineProtocolConstants.cmdGetAuth.toRadixString(16)})',
);
} catch (e) {
debugPrint('❌ sendSwitchControlRequest[TCP] 发送权限请求失败:$e');
rethrow;
@@ -105,51 +114,108 @@ class RemoteTcpDatasource {
return builder.takeBytes();
}
// TODO: 同意授权和拒绝授权
void sendSwitchControlResponse(bool bool, String deviceId) {
// TODO: 同意授权和拒绝授权
void sendSwitchControlResponse(bool agreed, String deviceId) {
debugPrint(
'📤 [进入了TCP层==== 被调用 - agreed=$agreed, deviceId=$deviceId',
);
// 🔥 添加详细的连接状态日志
debugPrint(
'📤 [进入了TCP层==== 当前 TCP 连接状态: isConnected=${_tcpClient.isConnected}',
);
if (!_tcpClient.isConnected) {
debugPrint('❌sendSwitchControlResponse [TCP] 无法发送权限响应:Socket 未连接');
debugPrint('❌ [TCP DataSource] 无法发送权限响应: Socket 未连接');
debugPrint('❌ [TCP DataSource] isConnected=${_tcpClient.isConnected}');
throw Exception('sendSwitchControlResponse TCP 未连接');
}
try {
// 构造响应 JSON(参考 Android 代码)
final response = bool
? {
'respond': {
'switchResult': true,
'deviceId': deviceId,
'holder': 'you',
},
}
: {
'respond': {
'switchResult': false,
'deviceId': deviceId,
'reason': 'holder_denied',
},
};
debugPrint('📡 [TCP DataSource] TCP 连接正常,准备发送数据...');
if(!bool){
debugPrint('sendSwitchControlResponse[TCP] 拒绝授权:$deviceId');
}else{
debugPrint('sendSwitchControlResponse[TCP] 同意授权:$deviceId');
}
//debugPrint('sendSwitchControlResponse[TCP] 同意授权:$deviceId');
final jsonStr = jsonEncode(response);
final jsonBytes = utf8.encode(jsonStr);
// 构造响应 JSON(参考 Android 代码)
final response = agreed
? {
'respond': {
'switchResult': true,
'deviceId': deviceId,
'holder': 'you',
},
}
: {
'respond': {
'switchResult': false,
'deviceId': deviceId,
'reason': 'holder_denied',
},
};
debugPrint('sendSwitchControlResponse[TCP] 响应权限请求:$jsonStr');
debugPrint(
'📝 [TCP DataSource] ${agreed ? "同意" : "拒绝"}授权: deviceId=$deviceId',
);
// 构造完整的协议包
final packet = buildSwitchControlPacket(jsonBytes);
final jsonStr = jsonEncode(response);
final jsonBytes = utf8.encode(jsonStr);
debugPrint('sendSwitchControlResponse[TCP] 完整数据包:${packet.map((b) => '0x${b.toRadixString(16)}').join(' ')}');
debugPrint('📦 [TCP DataSource] JSON 数据: $jsonStr');
debugPrint('📦 [TCP DataSource] JSON 字节长度: ${jsonBytes.length} 字节');
// 发送数据包
_tcpClient.sendnew(packet);
}
catch (e) {
debugPrint('❌ sendSwitchControlResponse[TCP] 响应权限请求失败:$e');
// 构造完整的协议包
final packet = buildSwitchControlPacket(jsonBytes);
_logger.logWithLevel('TCP DataSource] JSON 数据: $jsonStr' ,shouldLog: true);
debugPrint(
'📦 [TCP DataSource] 完整数据包(${packet.length}字节): ${packet.map((b) => '0x${b.toRadixString(16).padLeft(2, "0")}').join(' ')}',
);
// 🔥 发送前再次检查连接状态
if (!_tcpClient.isConnected) {
debugPrint('❌ [TCP DataSource] 发送前检查: Socket 已断开连接!');
throw Exception('TCP 连接在发送前断开');
}
// 发送数据包
debugPrint('🚀 [TCP DataSource] 正在调用 SendNew 发送数据...');
// 🔥 添加详细日志:记录发送的完整数据包(十六进制)
final hexString = packet
.map((byte) => '0x${byte.toRadixString(16).padLeft(2, '0')}')
.join(' ');
debugPrint('📦 [TCP DataSource] 完整数据包(${packet.length}字节): $hexString');
// 🔥 使用ILoggerService记录日志
_logger.logWithLevel(
'📦 [TCP DataSource] 完整数据包(${packet.length}字节): $hexString',
shouldLog: true,
);
// 🔥 记录JSON内容和字节数
_logger.logWithLevel(
'📝 [TCP DataSource] 发送内容: $jsonStr',
shouldLog: true,
);
_logger.logWithLevel(
'📊 [TCP DataSource] JSON字节数: ${jsonBytes.length}, 完整包字节数: ${packet.length}',
shouldLog: true,
);
_tcpClient.sendnew(packet);
debugPrint('✅ [TCP DataSource] TCP 响应已成功发送');
debugPrint('✅ [TCP DataSource] 发送字节数: ${packet.length}');
// 🔥 发送成功后的日志
_logger.logWithLevel(
'✅ [TCP DataSource] TCP 响应已成功发送 - agreed=$agreed, deviceId=$deviceId, 字节数=${packet.length}',
shouldLog: true,
);
} catch (e) {
debugPrint('❌ [TCP DataSource] 响应权限请求失败: $e');
debugPrint('❌ [TCP DataSource] 错误类型: ${e.runtimeType}');
debugPrint('❌ [TCP DataSource] 错误堆栈: ${StackTrace.current}');
rethrow;
}
}

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart';
import '../../../../core/logging/i_logger_service.dart';
@@ -19,11 +18,15 @@ class RemoteControlRepositoryImpl implements RemoteControlRepository {
final TcpClient _tcpClient;
final DiffSteerUseCase _diffSteer;
final RemoteHttpDatasource _remoteHttp;
final RemoteTcpDatasource _remoteTcp;
final RemoteTcpDatasource _remoteTcp;
final ILoggerService _logger = GetIt.I<ILoggerService>();
RemoteControlRepositoryImpl(this._tcpClient, this._diffSteer, this._remoteHttp, this._remoteTcp);
RemoteControlRepositoryImpl(
this._tcpClient,
this._diffSteer,
this._remoteHttp,
this._remoteTcp,
);
@override
void changeWebViewDirection(String direction) {
@@ -34,7 +37,9 @@ class RemoteControlRepositoryImpl implements RemoteControlRepository {
void sendControlMachineCmd(MachineControlStatusEntity status) {
// 1. 调用算法:将摇杆坐标 (x, y) 转换为左右轮电机转速
final speeds = _diffSteer.calculate(status.originX, status.originY);
_logger.logWithLevel('⚙️ [电机速度] left: ${speeds['left']}, right: ${speeds['right']}');
_logger.logWithLevel(
'⚙️ [电机速度] left: ${speeds['left']}, right: ${speeds['right']}',
);
// 2. 调用 Codec:生成协议要求的 8 字节 Payload
final payload = MachineProtocolCodec.encodeRemoteControlPayload(
@@ -47,39 +52,102 @@ class RemoteControlRepositoryImpl implements RemoteControlRepository {
);
// 3. 调用 TcpClient 发送指令
_tcpClient.sendRaw(
MachineProtocolConstants.cmdRemoteControl,
payload,
);
_tcpClient.sendRaw(MachineProtocolConstants.cmdRemoteControl, payload);
}
@override
Stream<RawPacket> get responseStream => _tcpClient.packetStream;
@override
Future<bool> requestControlPermission( String deviceName, String deviceId) async {
void sendTcpPermissionRequest(String deviceName) {
_remoteTcp.sendSwitchControlRequest(deviceName);
_logger.logWithLevel('🔑 [RemoteControl] TCP已发送权限请求');
}
@override
Future<Map<String, dynamic>> requestControlPermission(
String deviceName,
String deviceId,
) async {
try {
_remoteTcp.sendSwitchControlRequest(deviceName);
// debugPrint('🔑 [RemoteControl] TCP已发送权限请求');
_logger.logWithLevel('🔑 [RemoteControl] TCP已发送权限请求');
return await _remoteHttp.requestRemoteControlViaHttp(deviceName, deviceId);
// 1. 先发送 HTTP 权限请求检查权限
_logger.logWithLevel('🔑 [RemoteControl] 检查 HTTP 权限...');
final result = await _remoteHttp.requestRemoteControlViaHttp(
deviceName,
deviceId,
);
// 2. 如果没有权限或权限为 null,才发送 TCP 请求
final hasPermission = result['hasPermission'] as bool? ?? false;
if (!hasPermission) {
_logger.logWithLevel('🔑 [RemoteControl] 无权限,发送 TCP 权限请求');
_remoteTcp.sendSwitchControlRequest(deviceName);
} else {
_logger.logWithLevel('✅ [RemoteControl] 已有权限,无需 TCP 请求');
}
return result;
} catch (e) {
//sdebugPrint('❌ [RemoteControl] 权限请求失败:$e');
_logger.logWithLevel('❌ [RemoteControl] 权限请求失败:$e');
rethrow;
}
}
@override
void respondPermission(bool bool, String deviceId) {
// TODO: implement respondPermission
if (bool) {
_remoteTcp.sendSwitchControlResponse(true, deviceId);
} else {
_remoteTcp.sendSwitchControlResponse(false, deviceId);
void respondPermission(bool agreed, String deviceId) {
final timePrefix = DateTime.now().toIso8601String();
final logPrefix = '$timePrefix 📤 [RemoteControlRepository]';
debugPrint('$logPrefix =========================================');
debugPrint('$logPrefix respondPermission 被调用');
debugPrint('$logPrefix agreed: $agreed, deviceId: $deviceId');
_logger.logWithLevel(
'$logPrefix respondPermission 被调用 - agreed=$agreed, deviceId=$deviceId',
shouldLog: true,
);
try {
// 🔥 检查 _remoteTcp 是否为空
if (_remoteTcp == null) {
debugPrint('$logPrefix ❌ _remoteTcp 为空,无法发送 TCP 指令');
_logger.logWithLevel('$logPrefix ❌ _remoteTcp 为空', shouldLog: true);
throw Exception('_remoteTcp 为空');
}
// 🔥 检查 TCP 连接状态
debugPrint('$logPrefix 📡 TCP连接状态: ${_remoteTcp.isConnected}');
_logger.logWithLevel(
'$logPrefix 📡 TCP连接状态: ${_remoteTcp.isConnected}',
shouldLog: true,
);
if (agreed) {
debugPrint('$logPrefix ✅ 用户同意释放权限,发送同意响应');
_logger.logWithLevel('$logPrefix ✅ 准备发送同意响应 TCP 指令', shouldLog: true);
_remoteTcp.sendSwitchControlResponse(true, deviceId);
} else {
debugPrint('$logPrefix ❌ 用户拒绝释放权限,发送拒绝响应');
_logger.logWithLevel('$logPrefix ❌ 准备发送拒绝响应 TCP 指令', shouldLog: true);
_remoteTcp.sendSwitchControlResponse(false, deviceId);
}
debugPrint('$logPrefix ✅ TCP 响应发送完成');
_logger.logWithLevel('$logPrefix ✅ TCP 响应发送完成', shouldLog: true);
} catch (e) {
debugPrint('$logPrefix ❌ respondPermission 失败: $e');
debugPrint('$logPrefix ❌ 错误类型: ${e.runtimeType}');
_logger.logWithLevel(
'$logPrefix ❌ respondPermission 失败: $e',
shouldLog: true,
);
// 🔥 关键修复:重新抛出异常,让上层知道 TCP 发送失败了
rethrow;
}
debugPrint('$logPrefix =========================================');
}
///app退出远程控制后释放权限
///app退出远程控制后释放权限
@override
Future<bool> releasePermission(String platform) async {
return await _remoteHttp.releaseRemoteControlViaHttp(platform);

View File

@@ -13,8 +13,12 @@ abstract class RemoteControlRepository {
/// WebView 视角控制 (逻辑层面的切换,不涉及 TCP)
void changeWebViewDirection(String direction);
/// 新增:请求控制权限 (发送 0x04 指令)
Future<bool> requestControlPermission(String deviceName, String deviceId);
/// 新增:请求控制权限 (HTTP接口返回完整权限信息)
/// 返回: {hasPermission: bool, owner: String?}
Future<Map<String, dynamic>> requestControlPermission(String deviceName, String deviceId);
/// 🔥 新增: 发送 TCP 0x12 权限请求指令
void sendTcpPermissionRequest(String deviceName);
/// 新增:响应控制权限 同意和拒绝(发送 0x05 指令)
void respondPermission(bool bool, String deviceId);

View File

@@ -1,33 +1,25 @@
import 'package:fpdart/fpdart.dart';
import'package:maibu_satabot_v2/core/domain/usecases/base_usecase.dart';
import 'package:maibu_satabot_v2/core/domain/usecases/base_usecase.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/repositories/remote_control_repository.dart';
class RequestControlPermissionUseCase implements BaseUseCase<bool, RequestControlPermissionParams> {
// 注意:RequestControlPermissionUseCase 和 RequestControlPermissionParams
// 已经在专门的文件中定义 (request_control_permission_usecase.dart)
class RemoteControlUseCase
implements BaseUseCase<Map<String, dynamic>, NoParams> {
final RemoteControlRepository remoteControlRepository;
RequestControlPermissionUseCase(this.remoteControlRepository);
RemoteControlUseCase(this.remoteControlRepository);
@override
Future<Either<Failure, bool>> call(RequestControlPermissionParams params) async {
Future<Either<Failure, Map<String, dynamic>>> call(NoParams params) async {
try {
final result = await remoteControlRepository.requestControlPermission(
params.deviceName,
params.deviceId,
);
return Right(result);
// 实现远程控制用例的具体逻辑
// 这里可以添加具体的远程控制逻辑
return Right({});
} catch (e) {
return Left(Failure('请求控制权限失败:$e'));
return Left(Failure('远程控制失败:$e'));
}
}
}
class RequestControlPermissionParams {
final String deviceName;
final String deviceId;
const RequestControlPermissionParams({
required this.deviceName,
required this.deviceId,
});
}

View File

@@ -0,0 +1,31 @@
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import 'package:maibu_satabot_v2/core/domain/usecases/base_usecase.dart';
import '../repositories/remote_control_repository.dart';
class RequestControlPermissionUseCase extends BaseUseCase<Map<String, dynamic>, RequestControlPermissionParams> {
final RemoteControlRepository repository;
RequestControlPermissionUseCase(this.repository);
@override
Future<Either<Failure, Map<String, dynamic>>> call(RequestControlPermissionParams params) async {
try {
final result = await repository.requestControlPermission(params.deviceName, params.deviceId);
return right(result);
} catch (e) {
return left(Failure(e.toString()));
}
}
}
class RequestControlPermissionParams {
final String deviceName;
final String deviceId;
RequestControlPermissionParams({
required this.deviceName,
required this.deviceId,
});
}

View File

@@ -0,0 +1 @@
enum ControlMode { TCP, BLE, LOCAL, NONE, OTHER }

View File

@@ -1,4 +1,4 @@
import 'dart:async';
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -7,9 +7,12 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
import 'package:maibu_satabot_v2/features/devices/domain/repositories/device_repository.dart';
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/data/models/running_status_model.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/usecase/request_control_permission_usecase.dart';
import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_state.dart';
import '../../../../core/logging/i_logger_service.dart';
@@ -22,6 +25,7 @@ import '../../domain/usecase/remote_control_usecase.dart';
class RemoteControlCubit extends Cubit<RemoteControlState> {
final RemoteControlRepository _repository;
final RequestControlPermissionUseCase _requestControlPermissionUseCase;
final DeviceRepository _deviceRepository; // 🔥 注入设备仓库
Timer? _timer;
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期
final NetMessageDispatcher dispatcher;
@@ -35,11 +39,23 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
int _currentOriginX = 0;
int _currentOriginY = 0;
// 🔥 权限请求冷却期机制 - 防止Web端持续发送请求导致弹窗不断显示
DateTime? _lastPermissionResponseTime; // 记录上次响应权限请求的时间
static const _coolDownDuration = Duration(seconds: 5); // 冷却期5秒
// 🔥 同步锁 - 防止状态更新期间接收新请求导致重复弹窗
bool _isProcessingPermissionRequest = false;
// 🔥 获取带时间戳的日志前缀
String _getTimePrefix() {
final now = DateTime.now();
return '[${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}.${now.millisecond.toString().padLeft(3, '0')}]';
}
RemoteControlCubit(
this._repository,
this._requestControlPermissionUseCase,
this._deviceRepository, // 🔥 注入
this.dispatcher,
this.deviceStatusBloc, // 🔥 注入
) : super(
@@ -52,54 +68,55 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
_initDeviceStatusListener(); // 🔥 改为订阅 DeviceStatusBloc
}
// 🔥 新增:订阅 DeviceStatusBloc 的状态流,而不是直接监听 TCP
// 🔥 新增:订阅 DeviceStatusBloc 的状态流,而不是直接监听TCP
void _initDeviceStatusListener() {
_logger.logWithLevel('>>> [RemoteControl] begin 订阅 DeviceStatusBloc 状态流');
// // _logger.logWithLevel('>>> [RemoteControl] begin 订阅 DeviceStatusBloc 状态流');
_deviceStatusSub?.cancel();
_deviceStatusSub = deviceStatusBloc.stream.listen((deviceState) async {
if (deviceState is DeviceStatusUpdated) {
final voltage = deviceState.status.voltage;
final battery = deviceState.status.battery;
final controlMode = deviceState.status.controlMode == '3' ? '远程模式' : '本地模式';
final controlMode = deviceState.status.controlMode == '3'
? '远程模式'
: '本地模式';
final c = getNetworkDelay();
emit(state.copyWith(
runningStatusModel: state.runningStatusModel.copyWith(
voltage: voltage.toString(),
battery: battery.toString(),
controlMode: controlMode,
emit(
state.copyWith(
runningStatusModel: state.runningStatusModel.copyWith(
voltage: voltage.toString(),
battery: battery.toString(),
controlMode: controlMode,
),
battery: int.tryParse(battery) ?? 0,
ping: await c,
),
battery: int.tryParse(battery) ?? 0,
ping: await c,
));
);
_logger.logWithLevel('✅ [RemoteControl] 从 DeviceStatusBloc 收到更新: 电压=$voltage, 电量=$battery, 模式=$controlMode');
// // _logger.logWithLevel('✅ [RemoteControl] 从 DeviceStatusBloc 收到更新: 电压=$voltage, 电量=$battery, 模式=$controlMode');
}
});
_logger.logWithLevel('>>> [RemoteControl] ✅ DeviceStatusBloc 订阅已建立完成');
// _logger.logWithLevel('>>> [RemoteControl] ✅ DeviceStatusBloc 订阅已建立完成');
}
// 🔥 超简单方法:传入 IP,得到 ping 值
// 🔥 辅助方法:更新运行状态
void _updateStatusFromDevice(RunningStatusModel newStatus) {
debugPrint('✅ [_updateStatusFromDevice] 收到运行状态更新:$newStatus');
// // debugPrint('✅ [_updateStatusFromDevice] 收到运行状态更新:$newStatus');
if (!isClosed) {
emit(state.copyWith(
runningStatusModel: newStatus,
voltage: int.tryParse(newStatus.voltage) ?? 0,
battery: int.tryParse(newStatus.battery) ?? 0,
));
emit(
state.copyWith(
runningStatusModel: newStatus,
voltage: int.tryParse(newStatus.voltage) ?? 0,
battery: int.tryParse(newStatus.battery) ?? 0,
),
);
// debugPrint('✅ [更新运行状态] 电压:${newStatus.voltage}V, 电量:${newStatus.battery}%');
_logger.logWithLevel('✅ [更新运行状态] 电压:${newStatus.voltage}V, 电量:${newStatus.battery}%');
// debugPrint('✅ [更新运行状态] 电压:${newStatus.voltage}V, 电量:${newStatus.battery}%');
// _logger.logWithLevel('✅ [更新运行状态] 电压:${newStatus.voltage}V, 电量:${newStatus.battery}%');
}
}
@@ -114,25 +131,40 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
// });
// }
Future<void> _initPacketListener() async {
//print('>>> [RemoteControl] begin 初始化 0x12 监听器');
_logger.logWithLevel('>>> [RemoteControl] begin 0x12 监听器');
final logPrefix = '${_getTimePrefix()} 🔍 [RemoteControl] [0x12监听器]';
debugPrint('$logPrefix =========================================');
debugPrint('$logPrefix 开始初始化 0x12 监听器');
_logger.logWithLevel('$logPrefix 开始初始化 0x12 监听器', shouldLog: true);
_kickOutSub?.cancel(); // 防止重复监听
_kickOutSub = dispatcher.onCommand(0x12).listen((packet) {
//print('>>> [RemoteControl] 收到 0x12 原始包,payload 长度=${packet.payload.length}');
_logger.logWithLevel('>>> [RemoteControl] 收到 0x12 原始包,payload 长度=${packet.payload.length}');
final timeNow = _getTimePrefix();
debugPrint('$timeNow 🔍 [RemoteControl] [0x12监听器] ✅ 收到 0x12 原始包');
debugPrint(
'$timeNow 🔍 [RemoteControl] [0x12监听器] payload长度: ${packet.payload.length}',
);
_logger.logWithLevel(
'$timeNow 🔍 [RemoteControl] [0x12监听器] 收到 0x12 原始包,payload长度=${packet.payload.length}',
shouldLog: true,
);
try {
// 🔥 关键:手动去掉最后 2 个 CRC 字节
String jsonString;
if (packet.payload.length > 2) {
jsonString = utf8.decode(packet.payload.sublist(0, packet.payload.length - 2));
jsonString = utf8.decode(
packet.payload.sublist(0, packet.payload.length - 2),
);
} else {
jsonString = utf8.decode(packet.payload);
}
//print('>>> [RemoteControl] 去除 CRC 后的 JSON: $jsonString');
_logger.logWithLevel('>>> [RemoteControl] 去除 CRC 后的 JSON: $jsonString');
debugPrint('$timeNow 🔍 [RemoteControl] [0x12监听器] JSON内容: $jsonString');
_logger.logWithLevel(
'$timeNow 🔍 [RemoteControl] [0x12监听器] 去除CRC后的JSON: $jsonString',
shouldLog: true,
);
final jsonMap = jsonDecode(jsonString);
@@ -141,69 +173,139 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
final platform = jsonMap['platform'];
final respondData = jsonMap['respond'];
//print('>>> [RemoteControl] 📋 requestType=$requestType, platform=$platform');
_logger.logWithLevel('>>> [RemoteControl] 📋 requestType=$requestType, platform=$platform');
debugPrint(
'$timeNow 🔍 [RemoteControl] [0x12监听器] requestType: $requestType, platform: $platform, hasRespond: ${respondData != null}',
);
// 情况 1: 响应格式 - {"respond":{"switchResult":true,"deviceId":"...","holder":"you"}}
if (respondData != null && respondData is Map) {
final switchResult = respondData['switchResult'];
//print('>>> [RemoteControl] 📊 收到切换结果响应:switchResult = $switchResult');
_logger.logWithLevel('>>> [RemoteControl] 📊 收到切换结果响应:switchResult = $switchResult');
debugPrint('$timeNow 🔍 [RemoteControl] [0x12监听器] 📊 收到切换结果响应');
debugPrint(
'$timeNow 🔍 [RemoteControl] [0x12监听器] switchResult: $switchResult',
);
_logger.logWithLevel(
'$timeNow 🔍 [RemoteControl] [0x12监听器] 📊 收到切换结果响应 - switchResult: $switchResult',
shouldLog: true,
);
if (!isClosed) {
if (switchResult == true) {
// 切换成功,当前 APP 失去控制权
emit(state.copyWith(hasPermission: true, showPermissionRequestDialog: false));
print('>>> [RemoteControl] ✅ 权限已切');
// 切换成功,当前 APP 获得控制权
debugPrint('$timeNow 🔍 [RemoteControl] [0x12监听器] ✅ APP获得控制权');
debugPrint(
'$timeNow 🔍 [RemoteControl] [0x12监听器] 设置 hasPermission = true',
);
emit(state.copyWith(hasPermission: true));
} else {
// 切换失败或拒绝,保持当前状态
emit(state.copyWith(showPermissionRequestDialog: false));
print('>>> [RemoteControl] ❌ 权限切换失败/被拒绝');
// 切换失败或拒绝,APP 失去控制权
debugPrint('$timeNow 🔍 [RemoteControl] [0x12监听器] ❌ APP失去控制权');
debugPrint(
'$timeNow 🔍 [RemoteControl] [0x12监听器] 设置 hasPermission = false',
);
emit(
state.copyWith(
hasPermission: false,
// 🔥 修复:不强制关闭弹窗,让弹窗由用户操作控制
),
);
}
}
}
// 情况 2: 请求格式 - {"request":"switch_control","deviceId":"...","platform":"web",...}
else if (requestType == 'switch_control') {
final requestDeviceId = jsonMap['deviceId'];
final webLogPrefix =
'${_getTimePrefix()} 🚨 [RemoteControl] [Web端权限请求]';
debugPrint('$webLogPrefix =========================================');
debugPrint('$webLogPrefix 收到 switch_control 请求');
debugPrint('$webLogPrefix platform: $platform');
debugPrint('$webLogPrefix requestDeviceId: $requestDeviceId');
_logger.logWithLevel(
'$webLogPrefix 收到 switch_control 请求 - platform: $platform, deviceId: $requestDeviceId',
shouldLog: true,
);
// 🔥 关键判断:只有当是其他平台(web)请求时才弹窗
if (platform != null && platform.toString().toLowerCase() != 'app') {
//print('>>> [RemoteControl] 🚨 $platform 端请求控制权,打开弹窗询问用户');
_logger.logWithLevel('>>> [RemoteControl] 🚨 $platform 端请求控制权,打开弹窗询问用户');
if (!isClosed) {
emit(state.copyWith(showPermissionRequestDialog: true));
// 🔥 验证设备ID是否与当前控制的 targetDevice 一致
final currentDeviceId = state.targetDevice?.deviceName;
debugPrint('$webLogPrefix 当前控制设备ID: $currentDeviceId');
if (currentDeviceId != null && requestDeviceId == currentDeviceId) {
debugPrint('$webLogPrefix 设备ID匹配');
// 🔥 添加防重复检查:只有当弹窗还没显示时才弹出
if (!state.showPermissionRequestDialog) {
debugPrint('$webLogPrefix 弹出权限请求对话框');
_logger.logWithLevel(
'$webLogPrefix 设备ID匹配,弹出权限请求对话框',
shouldLog: true,
);
if (!isClosed) {
emit(
state.copyWith(
showPermissionRequestDialog: true,
requestingDeviceId: requestDeviceId?.toString(),
requestingPlatform: platform.toString(),
),
);
}
} else {
debugPrint('$webLogPrefix ⚠️ 弹窗已显示,忽略重复请求');
_logger.logWithLevel(
'$webLogPrefix ⚠️ 弹窗已显示,忽略重复请求',
shouldLog: true,
);
}
} else {
debugPrint('$webLogPrefix ⚠️ 设备ID不匹配,忽略');
debugPrint(
'$webLogPrefix currentDeviceId: $currentDeviceId, requestDeviceId: $requestDeviceId',
);
_logger.logWithLevel(
'$webLogPrefix ⚠️ 设备ID不匹配,忽略请求',
shouldLog: true,
);
}
} else {
//print('>>> [RemoteControl] ℹ️ APP 自己的请求回显,忽略不弹窗');
_logger.logWithLevel('>>> [RemoteControl] ℹ️ APP 自己的请求回显,忽略不弹窗');
debugPrint('$webLogPrefix ℹ️ APP自己的请求回显或platform为空,忽略不弹窗');
_logger.logWithLevel(
'$webLogPrefix ℹ️ APP自己的请求回显,忽略',
shouldLog: true,
);
}
debugPrint('$webLogPrefix =========================================');
}
// 情况 3: 异地登录通知 - {"request":"have_logged_in",...}
else if (requestType == 'have_logged_in') {
//print('>>> [RemoteControl] ⚠️ 检测到异地登录,打开弹窗提示');
_logger.logWithLevel('>>> [RemoteControl] ⚠️ 检测到异地登录,打开弹窗提示');
debugPrint('${_getTimePrefix()} ⚠️ [RemoteControl] 检测到异地登录');
_logger.logWithLevel(
'${_getTimePrefix()} ⚠️ [RemoteControl] 检测到异地登录,打开弹窗提示',
shouldLog: true,
);
if (!isClosed) {
emit(state.copyWith(showPermissionRequestDialog: true));
}
}
else {
// print('>>> [RemoteControl] ℹ️ 未知类型的 0x12 包,忽略');
_logger.logWithLevel('>>> [RemoteControl] ℹ️ 未知类型的 0x12 包,忽略');
} else {
debugPrint('${_getTimePrefix()} ℹ️ [RemoteControl] 未知类型的 0x12 包,忽略');
_logger.logWithLevel(
'${_getTimePrefix()} ℹ️ [RemoteControl] 未知类型的 0x12 包,忽略',
shouldLog: true,
);
}
} catch (e) {
//print('>>> [RemoteControl] ❌ 解析失败:$e');
_logger.logWithLevel('>>> [RemoteControl] ❌ 解析失败:$e');
debugPrint('${_getTimePrefix()} ❌ [RemoteControl] 解析失败:$e');
_logger.logWithLevel(
'${_getTimePrefix()} ❌ [RemoteControl] 解析失败:$e',
shouldLog: true,
);
}
});
final c = getNetworkDelay();
emit(state.copyWith(
ping: await c, // 这里直接使用异步返回的数值
));
//print('>>> [RemoteControl] ✅ 0x12 监听器已建立完成');
_logger.logWithLevel('>>> [RemoteControl] ✅ 0x12 监听器已建立完成');
}
// 2. 开启 100ms 控制循环 (在进入遥控页面或点击“开始”时调用)
/* void startControlLoop() {
/* void startControlLoop() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
// 🔥 关键修复:每次循环都重新读取最新的 state
@@ -211,21 +313,27 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
// 🔥 安全检查:无权限或急停时不发送
if (!state.hasPermission || state.isEmergency) {
debugPrint('⚠️ [定时器] 跳过发送 - hasPermission=${state.hasPermission}, isEmergency=${state.isEmergency}');
// debugPrint('⚠️ [定时器] 跳过发送 - hasPermission=${state.hasPermission}, isEmergency=${state.isEmergency}');
return;
}
debugPrint('⏰ [定时器] 发送控制指令 - originX=${currentEntity.originX}, originY=${currentEntity.originY}');
_logger.logWithLevel('真实的发送的实体 - originX: ${currentEntity.originX}, originY: ${currentEntity.originY}');
// debugPrint('⏰ [定时器] 发送控制指令 - originX=${currentEntity.originX}, originY=${currentEntity.originY}');
// _logger.logWithLevel('真实的发送的实体 - originX: ${currentEntity.originX}, originY: ${currentEntity.originY}');
_repository.sendControlMachineCmd(currentEntity);
});
emit(state.copyWith(status: RemoteControlStatus.controlling));
}*/
void startControlLoop() {
_timer?.cancel();
final logPrefix = '⏰ [RemoteControl] [控制循环]';
//debugPrint('$logPrefix =========================================');
//debugPrint('$logPrefix 启动控制循环,间隔: 100ms');
///debugPrint('$logPrefix =========================================');
_timer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
if (isClosed) {
// debugPrint('$logPrefix ❌ Cubit已关闭,取消定时器');
timer.cancel();
return;
}
@@ -242,25 +350,31 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
// 常规安全检查
if (!state.hasPermission) {
debugPrint('⚠️ [定时器] 无权限,跳过发送');
// debugPrint('$logPrefix ⚠️ 无权限,跳过发送 - hasPermission=false');
return;
}
if (state.isEmergency) {
debugPrint('🚨 [定时器] 急停状态,跳过发送');
// debugPrint('$logPrefix 🚨 急停状态,跳过发送 - isEmergency=true');
return;
}
_logger.logWithLevel('[定时器] 发送控制指令 - originX=${snapshot.originX}, originY=${snapshot.originY}');
debugPrint('📤 [定时器] 准备发送 - originX=${snapshot.originX}, originY=${snapshot.originY}');
// 发送控制指令
//debugPrint(
// '$logPrefix 📤 发送控制指令: originX=${snapshot.originX}, originY=${snapshot.originY}, mower=${snapshot.mowerSpeed}, lift=${snapshot.chassisLift}, ignition=${snapshot.ignitionStatus}, emergency=${snapshot.isEmergency}',
// );
_repository.sendControlMachineCmd(snapshot);
//debugPrint('$logPrefix ✅ 控制指令已发送');
});
}
//通过方法拿最新 state
//通过方法拿最新 state
MachineControlStatusEntity _getCurrentControlEntity() {
// 🔥 关键修复:必须copyWith创建新对象,避免引用竞态条件
final entity = state.controlEntity;
_logger.logWithLevel('真实的发送的实体 - originX: ${entity.originX}, originY: ${entity.originY}');
// _logger.logWithLevel(
// '真实的发送的实体 - originX: ${entity.originX}, originY: ${entity.originY}',
// );
return entity.copyWith(); // 返回副本,不是引用
}
@@ -275,8 +389,10 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
// 4. 更新功能开关 (比如割刀速度、灯光、点火等)
void updateFunction({int? mower, int? lift, int? ignition, bool? emergency}) {
// debugPrint('🔧 [updateFunction] 调用 - mower: $mower, lift: $lift, ignition: $ignition, emergency: $emergency');
_logger.logWithLevel('🔧 [updateFunction] 调用 - mower: $mower, lift: $lift, ignition: $ignition, emergency: $emergency');
// debugPrint('🔧 [updateFunction] 调用 - mower: $mower, lift: $lift, ignition: $ignition, emergency: $emergency');
// _logger.logWithLevel(
// '🔧 [updateFunction] 调用 - mower: $mower, lift: $lift, ignition: $ignition, emergency: $emergency',
// );
final updatedEntity = state.controlEntity.copyWith(
mower: mower,
lift: lift,
@@ -284,16 +400,18 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
emergency: emergency,
);
//emit(state.copyWith(controlEntity: updatedEntity));
emit(state.copyWith(
controlEntity: updatedEntity,
isEmergency: emergency ?? state.isEmergency,
));
debugPrint('✅ [updateFunction] 状态已更新并 emit');
_logger.logWithLevel('✅ [updateFunction] 状态已更新并 emit');
emit(
state.copyWith(
controlEntity: updatedEntity,
isEmergency: emergency ?? state.isEmergency,
),
);
// debugPrint('>>> [updateFunction] 状态已更新到emit');
// _logger.logWithLevel('>>> [updateFunction] 状态已更新到emit');
}
void updateOriginY(int y) {
debugPrint('📥 [updateOriginY] 被调用 - y=$y');
// debugPrint('📥 [updateOriginY] 被调用- y=$y');
// 🔥 参考Android版:直接更新成员变量
_currentOriginY = y;
@@ -301,11 +419,11 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
// 同时更新state(用于UI显示)
final updatedEntity = state.controlEntity.copyWith(y: y);
emit(state.copyWith(controlEntity: updatedEntity));
debugPrint('📝 [updateOriginY] state已更新 - originY=$y');
// debugPrint('📝 [updateOriginY] state已更新- originY=$y');
}
void updateOriginX(int x) {
debugPrint('📥 [updateOriginX] 被调用 - x=$x');
// debugPrint('📥 [updateOriginX] 被调用- x=$x');
// 🔥 参考Android版:直接更新成员变量
_currentOriginX = x;
@@ -313,12 +431,14 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
// 同时更新state(用于UI显示)
final updatedEntity = state.controlEntity.copyWith(x: x);
emit(state.copyWith(controlEntity: updatedEntity));
debugPrint('📝 [updateOriginX] state已更新 - originX=$x');
// debugPrint('📝 [updateOriginX] state已更新- originX=$x');
}
/// 🔥 安全方法:同时清零双轴,确保只发送一次完全停止指令
Future<void> stopAllMovement() async {
debugPrint('🛑 [stopAllMovement] 开始执行 - 当前成员变量: originX=$_currentOriginX, originY=$_currentOriginY');
// debugPrint(
// '🛑 [stopAllMovement] 开始执行- 当前成员变量: originX=$_currentOriginX, originY=$_currentOriginY',
// );
// 🔥 参考Android版:直接清零成员变量
_currentOriginX = 0;
@@ -326,7 +446,7 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
// 🔥 关键修复:先暂停定时器,防止定时器在停止期间发送旧的运动指令
_timer?.cancel();
_timer = null; // 🔥 彻底清空,防止重复启动
_timer = null; // 🔥 彻底清空,防止重复启用
final updatedEntity = state.controlEntity.copyWith(x: 0, y: 0);
@@ -334,20 +454,24 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
emit(state.copyWith(controlEntity: updatedEntity));
// 🔥 安全底线:停止指令必须无视权限强制发送!
debugPrint('🛑 [stopAllMovement] 双轴归零,发送停止指令 - originX=0, originY=0');
await _sendStopCommandRepeatedly(updatedEntity); // ✅ 等待所有指令发送完成
// debugPrint('🛑 [stopAllMovement] 双轴归零,发送停止指令- originX=0, originY=0');
await _sendStopCommandRepeatedly(updatedEntity); // 等待所有指令发送完成
// 🔥 恢复定时器
await Future.delayed(const Duration(milliseconds: 200));
startControlLoop();
debugPrint('✅ [stopAllMovement] 执行完成');
// debugPrint('>>> [stopAllMovement] 执行完成');
}
/// 🔥 统一方法:连续发送30次停止指令,彻底清空TCP缓冲区
Future<void> _sendStopCommandRepeatedly(MachineControlStatusEntity stopEntity) async {
debugPrint('🛑 [紧急停止] 开始连续发送30次停止指令 - originX=${stopEntity.originX}, originY=${stopEntity.originY}');
// 🔥 注意:_lastStopTime 已经在 stopAllMovement() 中设置了,这里不需要再设置
/// 🔥 统一方法:连续发送10次停止指令,彻底清空TCP缓冲区
Future<void> _sendStopCommandRepeatedly(
MachineControlStatusEntity stopEntity,
) async {
// debugPrint(
// '🛑 [紧急停止] 开始连续发送10次停止指令- originX=${stopEntity.originX}, originY=${stopEntity.originY}',
// );
// 🔥 注意:_lastStopTime 已经在stopAllMovement() 中设置了,这里不需要再设置
int sentCount = 0;
// 🔥 关键修复:每次发送间隔5ms,避免TCP合并/丢弃
@@ -355,14 +479,14 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
_repository.sendControlMachineCmd(stopEntity);
sentCount++;
if (i % 5 == 0) {
debugPrint('🛑 [紧急停止] 已发送第${i + 1}次');
// debugPrint('🛑 [紧急停止] 已发送第${i + 1}次');
}
if (i < 19) {
await Future.delayed(const Duration(milliseconds: 5));
}
}
// 🔥 延迟后再发送10次(双重保险,对抗网络抖动)
// 🔥 延迟后再发10次(双重保险,对抗网络抖动)
await Future.delayed(const Duration(milliseconds: 100));
for (int i = 0; i < 10; i++) {
_repository.sendControlMachineCmd(stopEntity);
@@ -372,7 +496,7 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
}
}
debugPrint('✅ [紧急停止] 所有30次停止指令已发出');
// debugPrint('>>> [紧急停止] 所有20次停止指令已发出');
}
// 5. 停止控制循环
@@ -396,7 +520,8 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
}
void toggleLeftPip() => emit(state.copyWith(showLeftPip: !state.showLeftPip));
void toggleRightPip() => emit(state.copyWith(showRightPip: !state.showRightPip));
void toggleRightPip() =>
emit(state.copyWith(showRightPip: !state.showRightPip));
@override
Future<void> close() {
@@ -408,34 +533,85 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
void updateChassisLift(int i) {}
void updateEmergency(bool bool) {
debugPrint('🚨 [急停] ${bool ? "触发急停!" : "解除急停"}');
// debugPrint('🚨 [急停] ${bool ? "触发急停!" : "解除急停"}');
updateFunction(emergency: bool);
}
void respondPermission(bool bool, String deviceId) {
void respondPermission(bool agreed, String deviceId) {
// 🔥 记录响应时间,开启冷却期
_lastPermissionResponseTime = DateTime.now();
//debugPrint('$logPrefix 开启权限请求冷却期,持续${_coolDownDuration.inSeconds}秒');
// 1. 关闭弹窗(立即关闭,防止重复点击)
emit(state.copyWith(showPermissionRequestDialog: false));
// 2. 发送响应到服务端
_repository.respondPermission(bool, deviceId);
// 3. 根据用户选择更新控制状态
if (bool) {
// 用户同意 → APP 失去控制权,Web 端获得控制权
emit(state.copyWith(hasPermission: false));
//路由到首页home
} else {
// 用户拒绝 → APP 继续保持控制权
emit(state.copyWith(hasPermission: true));
// 2. 发送响应到服务器
//debugPrint('$logPrefix 📤 发送权限响应命令到服务器');
try {
// 🔥 关键调用:发送 TCP 指令
debugPrint('{_coolDownDuration.inSeconds}秒');
_repository.respondPermission(agreed, deviceId);
} catch (e) {
debugPrint(' ❌ TCP权限响应指令发送失败: $e');
debugPrint(' ❌ 错误类型: ${e.runtimeType}');
_logger.logWithLevel(' TCP权限响应指令发送失败 $e', shouldLog: true);
// 即使发送失败,也要更新状态
rethrow;
}
// 3. 根据用户选择更新控制状态
if (agreed) {
emit(
state.copyWith(
hasPermission: false,
showPermissionRequestDialog: false,
),
);
} else {
// 用户拒绝 APP 继续保持控制权
// 🔥 必须同时设置 showPermissionRequestDialog: false,防止状态回退
emit(
state.copyWith(hasPermission: true, showPermissionRequestDialog: false),
);
}
debugPrint(
'${_getTimePrefix()} ====权限弹窗响应结束=====',
);
}
void requestControlPermissionS(String deviceName, String deviceId) async {
// 1. 关闭弹窗
emit(state.copyWith(showPermissionRequestDialog: false));
Future<void> requestControlPermissionS(
String deviceName,
String deviceId, {
String source = '自动',
}) async {
final timePrefix = _getTimePrefix();
final logPrefix = '$timePrefix 🔑 [RemoteControl] [请求权限接口-$source]';
debugPrint('$logPrefix =========================================');
debugPrint('$logPrefix 开始请求控制权');
debugPrint('$logPrefix deviceName: $deviceName');
debugPrint('$logPrefix platform: $deviceId');
debugPrint(
'$logPrefix 当前状态 hasPermission=${state.hasPermission}, showDialog=${state.showPermissionRequestDialog}',
);
_logger.logWithLevel(
'$logPrefix 开始请求控制权- deviceName: $deviceName, platform: $deviceId',
shouldLog: true,
);
// 2. 调用 UseCase
// 1. 只有当弹窗不是因Web 端请求权限而显示时,才关闭弹窗
// 避免 Web 端请求权限的弹窗被自动关闭(一闪而过的问题)
if (state.requestingPlatform == null) {
debugPrint('$logPrefix 关闭权限请求弹窗 (非Web端触发)');
emit(state.copyWith(showPermissionRequestDialog: false));
} else {
debugPrint('$logPrefix 保留弹窗 (Web端请求触发)');
}
// 2. 调用 UseCase 获取 HTTP 返回的完整权限信息
debugPrint('$logPrefix 调用 HTTP 接口查询权限状态..');
final result = await _requestControlPermissionUseCase(
RequestControlPermissionParams(
deviceName: deviceName,
@@ -445,51 +621,158 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
// 3. 处理结果
result.fold(
(failure) {
//print('❌ [RemoteControl] 请求控制权限失败:${failure.message}');
_logger.logWithLevel('❌ [RemoteControl] 请求控制权限失败:${failure.message}');
// 可以在这里显示错误提示或重新打开弹窗
(failure) {
final failLogPrefix =
'${_getTimePrefix()} 🔑 [RemoteControl] [请求权限接口-$source]';
debugPrint('$failLogPrefix APP请求控制权限失败: ${failure.message}');
debugPrint('$failLogPrefix 重新打开权限请求弹窗');
_logger.logWithLevel(
'$failLogPrefix APP请求控制权限失败: ${failure.message}',
shouldLog: true,
);
emit(state.copyWith(showPermissionRequestDialog: true));
},
(success) {
// 更新状态
//print('✅ [RemoteControl] 请求控制权限成功:$success');
_logger.logWithLevel('✅ [RemoteControl] 请求控制权限成功:$success',shouldLog: true);
///处理result
if(success){
emit(state.copyWith(hasPermission: true));
}else{
emit(state.copyWith(hasPermission: false));
}
print('✅ c:$success');
// 权限申请已发送,等待 0x12 回包更新状态
(permissionInfo) async {
final bool hasPermission =
permissionInfo['hasPermission'] as bool? ?? false;
final String? owner = permissionInfo['owner'] as String?;
final successLogPrefix =
'${_getTimePrefix()} 🔑 [RemoteControl] [请求权限接口-$source]';
debugPrint(
'$successLogPrefix APP HTTP返回 - hasPermission=$hasPermission, owner=$owner',
);
_logger.logWithLevel(
'$successLogPrefix APP HTTP返回 - hasPermission=$hasPermission, owner=$owner',
shouldLog: true,
);
// 🔥 关键逻辑: 如果没有权限 或owner为null,则发送TCP 请求
if (!hasPermission || owner == null) {
debugPrint('$successLogPrefix ⚠️ APP无权限或owner为null,发送TCP请求...');
_logger.logWithLevel(
'$successLogPrefix ⚠️ APP无权限或owner为null,发送TCP请求',
shouldLog: true,
);
// 🔥 发送TCP 0x12 权限请求指令
_repository.sendTcpPermissionRequest(deviceName);
// 等待 TCP 回包(通过监听器更新状态
debugPrint('$successLogPrefix 📡 TCP请求已发送,等待回包确认');
} else {
debugPrint('$successLogPrefix APP已有权限,直接更新UI');
_logger.logWithLevel(
'$successLogPrefix APP已有权限,直接更新UI',
shouldLog: true,
);
debugPrint(
'$successLogPrefix 当前 hasPermission 状态: ${state.hasPermission}',
);
// 🔥 正确的状态更新:使用最新状态
if (state.hasPermission != true) {
debugPrint('$successLogPrefix 🔄 更新 hasPermission = true');
emit(state.copyWith(hasPermission: true));
debugPrint('$successLogPrefix ✅ hasPermission 状态已更新为 true');
} else {
debugPrint('$successLogPrefix ⚠️ hasPermission 已是 true,无需更新');
// 强制触发UI刷新:通过临时改变其他属性
emit(state.copyWith(ping: state.ping + 1));
emit(state.copyWith(ping: state.ping));
}
}
},
);
debugPrint(
'${_getTimePrefix()} 🔑 [RemoteControl] [请求权限接口-$source] =========================================',
);
}
/// 发送底盘指令
/// 🔥 权限弹窗确认后调用- 发送TCP响应 + HTTP确认最终权限状态
Future<void> confirmPermissionResponse(
String deviceName,
String platform,
bool agreed,
) async {
final timePrefix = _getTimePrefix();
final logPrefix = '$timePrefix 🔑 [RemoteControl] [权限确认]';
debugPrint('$logPrefix ========进入TCP发送=================================');
debugPrint('$logPrefix ⚡️ confirmPermissionResponse 方法被调用');
debugPrint('$logPrefix 用户操作: ${agreed ? "同意" : "拒绝"}');
debugPrint('$logPrefix deviceName: $deviceName');
debugPrint('$logPrefix platform: $platform');
respondPermission(agreed, deviceName);
// 2. 调用 HTTP 接口获取最终权限状态
debugPrint('$logPrefix 📡 调用 HTTP 接口确认最终权限状态..');
/* final result = await _requestControlPermissionUseCase(
RequestControlPermissionParams(
deviceName: deviceName,
deviceId: platform,
),
);*/
// 3. 根据 HTTP 返回的真实权限状态更新UI
/* result.fold(
(failure) {
final failLogPrefix = '${_getTimePrefix()} 🔑 [RemoteControl] [权限确认]';
debugPrint('$failLogPrefix APP HTTP请求失败: ${failure.message}');
debugPrint('$failLogPrefix ⚠️ 保持当前状态不变');
},
(permissionInfo) {
final bool hasPermission =
permissionInfo['hasPermission'] as bool? ?? false;
final String? owner = permissionInfo['owner'] as String?;
final successLogPrefix =
'${_getTimePrefix()} 🔑 [RemoteControl] [权限确认]';
debugPrint(
'$successLogPrefix APP HTTP返回真实权限状态 hasPermission=$hasPermission, owner=$owner',
);
debugPrint(
'$successLogPrefix 🔄 正在更新UI - hasPermission: ${state.hasPermission} -> $hasPermission',
);
// 🔥 直接用HTTP 返回的权限状态覆盖
emit(state.copyWith(hasPermission: hasPermission));
debugPrint(
'$successLogPrefix 📊 UI已同步完成- 当前 hasPermission=$hasPermission',
);
},
);*/
debugPrint(
'${_getTimePrefix()} 🔑 [RemoteControl] [权限确认] =========================================',
);
}
/// 发送底盘指令
void sendChassisCommand(int i) {
// debugPrint(' [底盘指令] ${i}');
_logger.logWithLevel(' [底盘指令] ${i}');
// // debugPrint('>>> [底盘指令] ${i}');
// _logger.logWithLevel('>>> [底盘指令] ${i}');
updateFunction(lift: i);
}
/// 发送割刀指令
/// 发送割刀指令
void sendMowerCommand(int i) {
// debugPrint(' [割刀指令] ${i}');
_logger.logWithLevel(' [割刀指令] ${i}');
// // debugPrint('>>> [割刀指令] ${i}');
// _logger.logWithLevel('>>> [割刀指令] ${i}');
updateFunction(mower: i);
}
/// 发送点火指令
/// 发送点火指令
void sendFireCommand(int i) {
//void updateFunction({int? mower, int? lift, int? ignition, bool? emergency})
// updateFunction(mower:0, lift: 0, ignition: i, emergency: false);
debugPrint(' [点火指令] ${i}');
_logger.logWithLevel(' [点火指令] ${i}');
// updateFunction(mower:0, lift: 0, ignition: i, emergency: false);
// debugPrint('>>> [点火指令] ${i}');
// _logger.logWithLevel('>>> [点火指令] ${i}');
updateFunction(ignition: i);
}
// 发送障碍物识别指令
// 发送障碍物识别指令
void toggleObstacleRecognition() {
emit(state.copyWith(obstacleRecognitionFlag: !state.obstacleRecognitionFlag));
emit(
state.copyWith(obstacleRecognitionFlag: !state.obstacleRecognitionFlag),
);
}
void toggleTopLeftExpand() {
@@ -498,14 +781,14 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
Future<int> getNetworkDelay() async {
try {
// 直接 Ping 你的服务器 IP
// 直接 Ping 你的服务器IP
final ping = Ping('1.95.137.212', count: 1, timeout: 1);
// 等待一次结果
final data = await ping.stream.first;
if (data.response != null && data.response!.time != null) {
// 返回和 cmd 一样的毫秒值
// 返回和cmd 一样的毫秒值
return data.response!.time!.inMilliseconds;
} else {
return 9999;
@@ -514,14 +797,38 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
return 9999;
}
}
//app退出远程遥控界面释放权限
//app退出远程遥控界面释放权限
Future<bool> releasePermission(String platform) async {
return await _repository.releasePermission(platform);
}
/// 🔥 设置待控制的设备(从机器人列表点击进入时调用)
void setTargetDevice(DeviceEntity device) {
// debugPrint('🎯 [RemoteControl] 设置待控制设备 ${device.deviceName}');
// _logger.logWithLevel('🎯 [RemoteControl] 设置待控制设备 ${device.deviceName}');
// 🔥 通知后端订阅该设备
_deviceRepository.switchDevice("app", device.deviceName).then((result) {
result.fold(
(failure) {
// debugPrint('>>> [RemoteControl] 切换设备失败: ${failure.message}');
// _logger.logWithLevel('>>> [RemoteControl] 切换设备失败: ${failure.message}');
},
(success) {
// debugPrint('>>> [RemoteControl] 切换设备成功, code: $success');
// _logger.logWithLevel('>>> [RemoteControl] 切换设备成功, code: $success');
},
);
});
emit(state.copyWith(targetDevice: device));
}
/// 🔥 清除待控制设备(退出远程控制页时调用)
void clearTargetDevice() {
// debugPrint('🧹 [RemoteControl] 清除待控制设备');
// _logger.logWithLevel('🧹 [RemoteControl] 清除待控制设备');
emit(state.copyWith(targetDevice: null));
}
}

View File

@@ -1,19 +1,16 @@
import 'package:equatable/equatable.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
import 'package:maibu_satabot_v2/features/remote_control/data/models/running_status_model.dart';
import '../../domain/entities/machine_control_status_entity.dart';
enum RemoteControlStatus { initial, controlling, error }
enum ControlMode { TCP, BLE, LOCAL, NONE, OTHER }
class RemoteControlState extends Equatable {
final RemoteControlStatus status;
final MachineControlStatusEntity controlEntity; // 控制业务实体
final RunningStatusModel runningStatusModel;
//状态业务实体
final String? errorMessage;
final bool hasPermission; // 是否获得了 0x12 权限
final bool hasPermission; // 是否获得 0x12 权限
final bool isEmergency;
final bool isLocked;
final int ping;
@@ -22,6 +19,8 @@ class RemoteControlState extends Equatable {
final String permissionPlatform;
final String currentPlatform;
final bool showPermissionRequestDialog;
final String? requestingDeviceId; // 🔥 请求权限的设备ID
final String? requestingPlatform; // 🔥 请求权限的平台
final bool showLeftPip;
final bool showRightPip;
@@ -29,6 +28,8 @@ class RemoteControlState extends Equatable {
final bool obstacleRecognitionFlag; // 障碍物识别标志位(这是UI显示的)
final String obstacleFlag; //障碍物标志位
final DeviceEntity? targetDevice; // 🔥 待控制的设备
final RunningStatusModel runningStatusModel;
const RemoteControlState({
this.status = RemoteControlStatus.initial,
@@ -43,15 +44,18 @@ class RemoteControlState extends Equatable {
this.permissionPlatform = '',
this.currentPlatform = '',
this.showPermissionRequestDialog = false,
this.requestingDeviceId,
this.requestingPlatform,
this.showLeftPip = true,
this.showRightPip = true,
this.obstacleFlag = '',
required this.runningStatusModel,
this.topRightIsExpanded = false,
this.obstacleRecognitionFlag = true,
this.targetDevice,
});
// 方便 UI 更新部分属性
// 便利 UI 更新部分属性
RemoteControlState copyWith({
RemoteControlStatus? status,
MachineControlStatusEntity? controlEntity,
@@ -65,12 +69,15 @@ class RemoteControlState extends Equatable {
String? permissionPlatform,
String? currentPlatform,
bool? showPermissionRequestDialog,
String? requestingDeviceId,
String? requestingPlatform,
bool? showLeftPip,
bool? showRightPip,
RunningStatusModel? runningStatusModel,
String? obstacleFlag,
bool? topRightIsExpanded,
bool? obstacleRecognitionFlag,
DeviceEntity? targetDevice,
}) {
return RemoteControlState(
@@ -87,12 +94,15 @@ class RemoteControlState extends Equatable {
currentPlatform: currentPlatform ?? this.currentPlatform,
showPermissionRequestDialog:
showPermissionRequestDialog ?? this.showPermissionRequestDialog,
requestingDeviceId: requestingDeviceId ?? this.requestingDeviceId,
requestingPlatform: requestingPlatform ?? this.requestingPlatform,
showLeftPip: showLeftPip ?? this.showLeftPip,
showRightPip: showRightPip ?? this.showRightPip,
runningStatusModel: runningStatusModel ?? this.runningStatusModel,
obstacleFlag: obstacleFlag ?? this.obstacleFlag,
topRightIsExpanded: topRightIsExpanded ?? this.topRightIsExpanded,
obstacleRecognitionFlag: obstacleRecognitionFlag ?? this.obstacleRecognitionFlag,
targetDevice: targetDevice ?? this.targetDevice,
);
}
@@ -111,12 +121,15 @@ class RemoteControlState extends Equatable {
permissionPlatform,
currentPlatform,
showPermissionRequestDialog,
requestingDeviceId,
requestingPlatform,
showLeftPip,
showRightPip,
runningStatusModel,
obstacleFlag,
topRightIsExpanded,
obstacleRecognitionFlag,
targetDevice,
];

View File

@@ -0,0 +1,27 @@
class RunningStatusModel {
final String controlMode;
final String voltage;
final String deviceName;
final String deviceId;
const RunningStatusModel({
this.controlMode = '',
this.voltage = '0',
this.deviceName = '',
this.deviceId = '',
});
RunningStatusModel copyWith({
String? controlMode,
String? voltage,
String? deviceName,
String? deviceId,
}) {
return RunningStatusModel(
controlMode: controlMode ?? this.controlMode,
voltage: voltage ?? this.voltage,
deviceName: deviceName ?? this.deviceName,
deviceId: deviceId ?? this.deviceId,
);
}
}

View File

@@ -9,6 +9,7 @@ import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../../../core/app/app_user_cubit.dart';
import '../../../../core/router/route_paths.dart';
import '../../../devices/domain/entities/device_entity.dart';
import '../../../devices/presentation/bloc/devices_cubit.dart';
import '../bloc/remote_control_cubit.dart';
import '../bloc/remote_control_state.dart';
@@ -30,40 +31,79 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
String _videoStreamUrl = "";
late RemoteControlCubit _cubit;
DevicesCubit? _devicesCubit;
StreamSubscription? _permissionSubscription; // 🔥 权限监听订阅
bool _isShowingPermissionDialog = false; // 🔥 防止弹窗重复显示
bool _isLoadingPermission = true; // 🔥 标记是否正在加载权限状态
@override
void didChangeDependencies() {
super.didChangeDependencies();
_cubit = context.read<RemoteControlCubit>();
_devicesCubit = context.read<DevicesCubit>();
// 🔥 监听权限弹窗状态变化 (只订阅一次)
if (_permissionSubscription == null) {
_permissionSubscription = _cubit.stream.listen((state) {
// 🔥 加强防重复逻辑:只在状态真正变化且不在显示弹窗时才显示
if (mounted &&
state.showPermissionRequestDialog &&
!_isShowingPermissionDialog) {
_isShowingPermissionDialog = true;
debugPrint('🔔 [权限弹窗] 检测到 showPermissionRequestDialog=true,准备显示弹窗');
// 使用微任务确保标志位已设置
Future.microtask(() {
_showPermissionDialog(state).then((_) {
debugPrint('🔔 [权限弹窗] 弹窗已关闭,重置标志位');
_isShowingPermissionDialog = false; // 弹窗关闭后重置标志
});
});
}
});
}
}
@override
void initState() {
super.initState();
// 1. 锁定横屏
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
// 2. 隐藏状态栏和虚拟按键
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
WidgetsBinding.instance.addPostFrameCallback((_) {
final remoteCubit = context.read<RemoteControlCubit>();
final devicesCubit = context.read<DevicesCubit>();
// debugPrint('🔍 [RemoteControl] initState - targetDevice: ${remoteCubit.state.targetDevice}');
// 🔥 检查 targetDevice 是否存在
if (remoteCubit.state.targetDevice == null) {
// debugPrint('❌ [RemoteControl] targetDevice 为空,无法进入远程控制');
return;
}
// 🔥 弹窗显示当前控制的设备信息
_showTargetDeviceDialog(context, remoteCubit.state.targetDevice!);
// 🔥 只在控制循环未启动时才启动
remoteCubit.startControlLoop();
// debugPrint('✅ [RemoteControl] 控制循环已启动');
});
}
@override
void dispose() {
// 释放远程控制权限
// _cubit.releasePermission("app");
_cubit.releasePermission("app");
//print("远程控制要推出啦");
final deviceState = _devicesCubit?.state;
if (deviceState?.selectedDevice != null) {
// _cubit.releasePermission("app");
}
//final deviceState = _devicesCubit?.state;
//if (deviceState?.selectedDevice != null) {
// _cubit.releasePermission("app");
// }
_permissionSubscription?.cancel(); // 🔥 取消订阅
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
_cubit.stopControlLoop();
@@ -77,12 +117,36 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
final deviceState = context.watch<DevicesCubit>().state;
final currentDevice = deviceState.selectedDevice;
final remoteCubit = context.read<RemoteControlCubit>();
final hasPermission = remoteCubit.state.hasPermission;
///context.read<RemoteControlCubit>().requestControlPermissionS(currentDevice!.deviceName, "app");
if (currentDevice == null) {
return _buildOfflineScaffold();
}
context.read<RemoteControlCubit>().requestControlPermissionS(currentDevice!.deviceName, "app");
// 🔥 只在没有权限时自动请求,避免重复调用
if (!hasPermission && _isLoadingPermission) {
// 🔥 只使用 targetDevice
final deviceName = remoteCubit.state.targetDevice?.deviceName;
if (deviceName != null && deviceName.isNotEmpty) {
// debugPrint('🔑 [RemoteControl] 检测到无权限,自动发送权限请求 - deviceName: $deviceName');
setState(() => _isLoadingPermission = false); // 🔥 标记为已请求
remoteCubit.requestControlPermissionS(deviceName, "app").then((_) {
if (mounted) {
setState(() => _isLoadingPermission = false); // 🔥 请求完成后重置
}
});
} else {
// debugPrint('❌ [RemoteControl] 无法发送权限请求 - targetDevice 为空');
// debugPrint(' targetDevice: ${remoteCubit.state.targetDevice}');
setState(() => _isLoadingPermission = false);
}
} else {
// debugPrint('✅ [RemoteControl] 已有权限或已请求过,跳过');
}
return Scaffold(
backgroundColor: Colors.black,
@@ -92,24 +156,50 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
Positioned.fill(
child: BlocBuilder<RemoteControlCubit, RemoteControlState>(
// 只有当显示隐藏状态改变时才重构,内部的拖拽由组件自身 State 处理,不影响这里
buildWhen: (p, c) => p.showLeftPip != c.showLeftPip || p.showRightPip != c.showRightPip,
buildWhen: (p, c) =>
p.showLeftPip != c.showLeftPip ||
p.showRightPip != c.showRightPip,
builder: (context, state) {
final deviceId = context.watch<DevicesCubit>().state.selectedDevice?.deviceName;
if (deviceId != null && userState.user != null && userState.user!.token != null) {
_videoStreamUrl = "webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=${userState.user!.token}";
// 🔥 只从 targetDevice 获取 deviceId
final targetDevice = context
.watch<RemoteControlCubit>()
.state
.targetDevice;
final deviceId = targetDevice?.deviceName;
// debugPrint('🔍 [WebRTC检查] targetDevice: $targetDevice, deviceId: $deviceId');
// debugPrint('🔍 [WebRTC检查] user: ${userState.user != null}, token: ${userState.user?.token != null}');
if (deviceId != null &&
userState.user != null &&
userState.user!.token != null) {
_videoStreamUrl =
"webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=${userState.user!.token}";
// debugPrint('🎬 [WebRTC] URL构建成功: $_videoStreamUrl');
} else {
_videoStreamUrl = '';
// debugPrint('❌ [WebRTC] URL构建失败 - deviceId: $deviceId, hasUser: ${userState.user != null}, hasToken: ${userState.user?.token != null}');
}
final int originY = context.watch<RemoteControlCubit>().state.controlEntity.originY;
debugPrint("${originY},originY");
final int originY = context
.watch<RemoteControlCubit>()
.state
.controlEntity
.originY;
// debugPrint("${originY},originY");
return WebRTCLocalPlayer(
// 这里的 URL 拼接根据你的后端规则
// streamUrl: "webrtc://${TCPConsts.TCP_IP}/live/livestream/${currentDevice.deviceName}?token=${userState.user!.token}",
streamUrl: _videoStreamUrl,
showLeftPip: state.showLeftPip, // 从 Cubit 状态中读取
showRightPip: state.showRightPip, // 从 Cubit 状态中读取
isFrontMain: context.watch<RemoteControlCubit>().state.controlEntity.originY >= 0,
isFrontMain:
context
.watch<RemoteControlCubit>()
.state
.controlEntity
.originY >=
0,
);
},
),
@@ -118,14 +208,44 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
BlocBuilder<RemoteControlCubit, RemoteControlState>(
buildWhen: (p, c) => p.isEmergency != c.isEmergency,
builder: (context, state) {
return state.isEmergency ? const Positioned.fill(child: EmergencyOverlay()) : const SizedBox.shrink();
return state.isEmergency
? const Positioned.fill(child: EmergencyOverlay())
: const SizedBox.shrink();
},
),
SafeArea(
child: Column(
children: [
const TopStatusBar(),
// 🔥 用 BlocConsumer 包裹,确保 hasPermission 变化时重建
BlocConsumer<RemoteControlCubit, RemoteControlState>(
listenWhen: (p, c) => p.hasPermission != c.hasPermission,
listener: (context, state) {
debugPrint(
'🔍 [RemoteControlPage] BlocConsumer listener - hasPermission=${state.hasPermission}',
);
},
buildWhen: (p, c) =>
p.hasPermission != c.hasPermission ||
p.isLocked != c.isLocked ||
p.topRightIsExpanded != c.topRightIsExpanded ||
p.obstacleRecognitionFlag != c.obstacleRecognitionFlag ||
p.showLeftPip != c.showLeftPip ||
p.showRightPip != c.showRightPip ||
p.ping != c.ping ||
p.runningStatusModel.voltage !=
c.runningStatusModel.voltage ||
p.runningStatusModel.controlMode !=
c.runningStatusModel.controlMode ||
p.battery != c.battery,
builder: (context, state) {
// 🔥 调试日志:确认 BlocBuilder 接收到的状态值
/* debugPrint(
'🔍 [RemoteControlPage] BlocConsumer builder - hasPermission=${state.hasPermission}',
);*/
return TopStatusBar(remoteState: state);
},
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
@@ -133,7 +253,10 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
final double centerAreaWidth = constraints.maxWidth * 0.5;
// 关键:使用 BlocBuilder 局部刷新摇杆,不要 watch 整个 Page
return BlocBuilder<RemoteControlCubit, RemoteControlState>(
return BlocBuilder<
RemoteControlCubit,
RemoteControlState
>(
// 只有 isLocked 改变时才重构摇杆区域,摇杆坐标改变由内部处理
buildWhen: (p, c) => p.isLocked != c.isLocked,
builder: (context, remoteState) {
@@ -148,7 +271,10 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
width: sideAreaWidth,
child: Align(
alignment: Alignment.bottomCenter,
child: LeftJoystickArea(isLocked: remoteState.isLocked, width: sideAreaWidth * 0.45),
child: LeftJoystickArea(
isLocked: remoteState.isLocked,
width: sideAreaWidth * 0.45,
),
),
),
@@ -157,7 +283,9 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
width: centerAreaWidth,
child: Align(
alignment: Alignment.bottomCenter,
child: CenterControlArea(totalWidth: centerAreaWidth),
child: CenterControlArea(
totalWidth: centerAreaWidth,
),
),
),
@@ -166,7 +294,10 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
width: sideAreaWidth,
child: Align(
alignment: Alignment.bottomCenter,
child: RightJoystickArea(isLocked: remoteState.isLocked, width: sideAreaWidth * 0.45),
child: RightJoystickArea(
isLocked: remoteState.isLocked,
width: sideAreaWidth * 0.45,
),
),
),
],
@@ -180,15 +311,152 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
],
),
),
// 权限弹窗
BlocBuilder<RemoteControlCubit, RemoteControlState>(
buildWhen: (p, c) => p.showPermissionRequestDialog != c.showPermissionRequestDialog,
builder: (context, state) {
if (state.showPermissionRequestDialog) {
return _buildPermissionDialog(context, state);
],
),
);
}
// 🔥 显示权限请求弹窗 (使用 showDialog 替代 Overlay)
Future<void> _showPermissionDialog(RemoteControlState state) async {
debugPrint('🔔 [权限弹窗] _showPermissionDialog 被调用');
final requestingDeviceId = state.requestingDeviceId ?? '未知设备';
final requestingPlatform = state.requestingPlatform ?? '未知平台';
final dialogContent =
'$requestingPlatform 端正在请求控制权\n\n'
'设备ID: $requestingDeviceId\n\n'
'是否同意释放控制权?';
debugPrint(
'🔔 [权限弹窗] 准备显示弹窗 - requestingPlatform=$requestingPlatform, requestingDeviceId=$requestingDeviceId',
);
showDialog(
context: context,
barrierDismissible: false, // 禁止点击外部关闭
builder: (dialogContext) => AlertDialog(
title: Text(
AppLocalizations.of(
dialogContext,
).translate('remote_control.permission_request_title'),
),
content: Text(dialogContent),
actions: [
TextButton(
onPressed: () async {
// debugPrint('👆 [权限弹窗] 用户点击了拒绝按钮');
final deviceId =
context
.read<DevicesCubit>()
.state
.selectedDevice
?.deviceName ??
"";
final remoteCubit = context.read<RemoteControlCubit>();
// 🔥 添加详细状态日志 - 检查为什么TCP指令发不出去
// debugPrint('👆 [权限弹窗] ====================状态检查====================');
// debugPrint('👆 [权限弹窗] showPermissionRequestDialog: ${remoteCubit.state.showPermissionRequestDialog}');
// debugPrint('👆 [权限弹窗] targetDevice: ${remoteCubit.state.targetDevice}');
// debugPrint('👆 [权限弹窗] requestingDeviceId: ${remoteCubit.state.requestingDeviceId}');
// debugPrint('👆 [权限弹窗] hasPermission: ${remoteCubit.state.hasPermission}');
// debugPrint('👆 [权限弹窗] ==============================================');
/* debugPrint(
'📋 [权限弹窗] targetDevice=${remoteCubit.state.targetDevice}',
);*/
// 🔥 先发送 TCP 响应,等完成后再关闭弹窗
// debugPrint('🔑 [权限弹窗] 用户拒绝,调用 confirmPermissionResponse');
final targetDevice = remoteCubit.state.targetDevice;
if (targetDevice != null) {
debugPrint(
'✅ [权限弹窗] targetDevice 不为空,开始调用 confirmPermissionResponse',
);
await remoteCubit.confirmPermissionResponse(
targetDevice.deviceName,
'app',
false,
);
// debugPrint('✅ [权限弹窗] TCP响应已发送,准备关闭弹窗');
} else {
debugPrint(
'❌ [权限弹窗] targetDevice 为空,无法调用 confirmPermissionResponse',
);
}
return const SizedBox.shrink();
// 🔥 关闭弹窗(在 TCP 发送完成后)
Navigator.pop(dialogContext);
},
child: Text(
AppLocalizations.of(
dialogContext,
).translate('remote_control.refuse'),
),
),
TextButton(
onPressed: () async {
debugPrint('👆 [权限弹窗] 用户点击了同意按钮');
final deviceId =
context
.read<DevicesCubit>()
.state
.selectedDevice
?.deviceName ??
"";
final remoteCubit = context.read<RemoteControlCubit>();
// 🔥 添加详细状态日志 - 检查为什么TCP指令发不出去
/* debugPrint(
'👆 [权限弹窗] ====================状态检查====================',
);
debugPrint(
'👆 [权限弹窗] showPermissionRequestDialog: ${remoteCubit.state.showPermissionRequestDialog}',
);
debugPrint(
'👆 [权限弹窗] targetDevice: ${remoteCubit.state.targetDevice}',
);
debugPrint(
'👆 [权限弹窗] requestingDeviceId: ${remoteCubit.state.requestingDeviceId}',
);
debugPrint(
'👆 [权限弹窗] hasPermission: ${remoteCubit.state.hasPermission}',
);
debugPrint(
'👆 [权限弹窗] ==============================================',
);
debugPrint(
'📋 [权限弹窗] targetDevice=${remoteCubit.state.targetDevice}',
);*/
// 🔥 先发送 TCP 响应,等完成后再关闭弹窗
debugPrint('🔑 [权限弹窗] 用户同意,调用 confirmPermissionResponse');
final targetDevice = remoteCubit.state.targetDevice;
if (targetDevice != null) {
debugPrint(
'✅ [权限弹窗] targetDevice 不为空',
);
await remoteCubit.confirmPermissionResponse(
targetDevice.deviceName,
'app',
true,
);
debugPrint('✅ [权限弹窗] TCP响应已发送,准备关闭弹窗');
} else {
debugPrint(
'❌ [权限弹窗] targetDevice 为空,无法调用 confirmPermissionResponse',
);
}
// 🔥 关闭弹窗(在 TCP 发送完成后)
Navigator.pop(dialogContext);
},
child: Text(
AppLocalizations.of(
dialogContext,
).translate('remote_control.agree'),
),
),
],
),
@@ -204,36 +472,51 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
children: [
const Icon(Icons.signal_wifi_off, color: Colors.white, size: 60),
const SizedBox(height: 20),
Text(AppLocalizations.of(context).translate('remote_control.device_disconnected'), style: const TextStyle(color: Colors.white, fontSize: 18)),
Text(
AppLocalizations.of(
context,
).translate('remote_control.device_disconnected'),
style: const TextStyle(color: Colors.white, fontSize: 18),
),
const SizedBox(height: 20),
ElevatedButton(onPressed: () => context.pop(), child: Text(AppLocalizations.of(context).translate('remote_control.back'))),
ElevatedButton(
onPressed: () => context.pop(),
child: Text(
AppLocalizations.of(context).translate('remote_control.back'),
),
),
],
),
),
);
}
Widget _buildPermissionDialog(BuildContext context, RemoteControlState state) {
final deviceId = context.read<DevicesCubit>().state.selectedDevice?.deviceName ?? "";
return Container(
color: Colors.black54,
child: AlertDialog(
title: Text(AppLocalizations.of(context).translate('remote_control.permission_request_title')),
// content: Text("${state.permissionPlatform}端正请求控制权,同意释放吗?"),
content: Text(AppLocalizations.of(context).translate('remote_control.permission_request_content')),
/// 🔥 显示当前控制设备的弹窗
void _showTargetDeviceDialog(BuildContext context, DeviceEntity device) {
showDialog(
context: context,
barrierDismissible: true,
builder: (ctx) => AlertDialog(
title: const Text('🎮 远程控制'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'设备名称: ${device.deviceName}',
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 8),
Text(
'设备ID: ${device.deviceName}',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
actions: [
TextButton(onPressed: () => context.read<RemoteControlCubit>().respondPermission(false, deviceId), child: Text(AppLocalizations.of(context).translate('remote_control.refuse'))),
TextButton(
onPressed: () {
context.read<RemoteControlCubit>().respondPermission(true, deviceId);
// 🔥 用户同意,延迟跳转到首页
// Future.delayed(const Duration(milliseconds: 1000), () {
// if (context.mounted) {
// context.go(RoutePaths.home);
// }
// });
},
child: Text(AppLocalizations.of(context).translate('remote_control.agree')),
onPressed: () => Navigator.pop(ctx),
child: const Text('确定'),
),
],
),

View File

@@ -3,24 +3,44 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/control_mode.dart';
import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_state.dart';
import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/status_chip.dart';
import 'package:cc_ui_kit/cc_ui_kit.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/logging/i_logger_service.dart';
import '../../../devices/domain/entities/device_entity.dart';
import '../../../devices/presentation/bloc/devices_cubit.dart';
import '../../data/models/running_status_model.dart';
import '../bloc/remote_control_cubit.dart';
class TopStatusBar extends StatelessWidget {
const TopStatusBar({super.key});
/// 🔥 从父组件传入状态,不依赖 context.watch
final RemoteControlState remoteState;
const TopStatusBar({super.key, required this.remoteState});
@override
Widget build(BuildContext context) {
// 监听全局设备状态
final deviceState = context.watch<DevicesCubit>().state;
final device = deviceState.selectedDevice;
// 🔥 添加调试日志:确认接收到的状态值
//debugPrint('🔍 [TopStatusBar] build - isLocked=${remoteState.isLocked}');
// 监听全局设备状态 (DevicesCubit 需要 watch,因为设备选择可能变化)
// 🔥 使用 try-catch 保护,防止 BlocProvider 缺失导致整个 widget 树崩溃(白屏)
DeviceEntity? device;
try {
final deviceState = context.watch<DevicesCubit>().state;
device = deviceState.selectedDevice;
} catch (e) {
debugPrint('⚠️ [TopStatusBar] 读取 DevicesCubit 失败: $e');
// DevicesCubit 不可用时使用 targetDevice 作为降级方案
try {
device = context.read<RemoteControlCubit>().state.targetDevice;
} catch (e2) {
debugPrint('⚠️ [TopStatusBar] 读取 targetDevice 也失败: $e2');
}
}
var _remoteControlCubit = context.read<RemoteControlCubit>();
ControlMode _parseControlMode(String modeString) {
switch (modeString.toUpperCase()) {
@@ -39,124 +59,171 @@ class TopStatusBar extends StatelessWidget {
}
}
// 监听局部遥控状态
final remoteState = context.watch<RemoteControlCubit>().state;
return Padding(
padding: const EdgeInsets.fromLTRB(1, 12, 1, 12),
child: Row(
children: [
// 1. 返回按钮 (对应 SimpleSmallFunctionButton)
_buildIconButton("assets/svgs/remote_back.svg", () => context.pop()),
const SizedBox(width: 8),
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 1. 返回按钮 (对应 SimpleSmallFunctionButton)
_buildIconButton("assets/svgs/remote_back.svg", () {
// 🔥 兼容两种导航栈:GoRouter 和 Navigator
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.pop();
}
}),
const SizedBox(width: 8),
// 2. 控制状态 (对应 StatusChipLeft)
StatusChip(
text: remoteState.hasPermission
? AppLocalizations.of(context).translate('remote_control.controlling')
: AppLocalizations.of(context).translate('remote_control.not_controlling'),
color: remoteState.hasPermission
? const Color(0xFF1DB954)
: Colors.red,
icon: Icons.eighteen_mp,
breathing: !remoteState.hasPermission,
onTap: () {
if (!remoteState.hasPermission) {
// 弹出请求权限对话框逻辑
context.read<RemoteControlCubit>().togglePermissionDialog(true);
}
},
),
const SizedBox(width: 8),
// 2. 控制状态 (对应 StatusChipLeft)
StatusChip(
text: remoteState.hasPermission
? AppLocalizations.of(
context,
).translate('remote_control.controlling')
: AppLocalizations.of(
context,
).translate('remote_control.not_controlling'),
color: remoteState.hasPermission
? const Color(0xFF1DB954)
: Colors.red,
icon: Icons.eighteen_mp,
breathing: !remoteState.hasPermission,
onTap: () {
if (!remoteState.hasPermission) {
// 🔥 点击后重新请求权限,和刚进入页面时的逻辑一致
debugPrint('🔑 [TopStatusBar] 👆 用户手动点击“未在控制”,重新请求权限');
final targetDevice =
_remoteControlCubit.state.targetDevice;
if (targetDevice != null) {
_remoteControlCubit.requestControlPermissionS(
targetDevice.deviceName,
'app',
source: '手动点击',
);
} else {
debugPrint('❌ [TopStatusBar] targetDevice 为空,无法请求权限');
}
}
},
),
const SizedBox(width: 8),
// 3. 锁定状态 (对应 SmallFunctionButton)
_buildIconButton(
remoteState.isLocked
? "assets/svgs/remote_lock.svg"
: "assets/svgs/remote_lock_open.svg",
() => context.read<RemoteControlCubit>().toggleLock(),
isSelected: remoteState.isLocked,
),
const SizedBox(width: 8),
// 3. 锁定状态 (对应 SmallFunctionButton)
_buildIconButton(
remoteState.isLocked
? "assets/svgs/remote_lock.svg"
: "assets/svgs/remote_lock_open.svg",
() => context.read<RemoteControlCubit>().toggleLock(),
isSelected: remoteState.isLocked,
),
const SizedBox(width: 8),
// 4. 刷新按钮
_buildIconButton("assets/svgs/remote_refresh.svg", () {}),
// const SizedBox(width: 8),
// 5. 火技能按钮 (对应 SmallFunctionButton)
//_buildIconButton("assets/svgs/fire.svg", () {}),
_buildSliderBox(AppLocalizations.of(context).translate('remote_control.fire_skill'), false,context),
// 4. 刷新按钮
_buildIconButton("assets/svgs/remote_refresh.svg", () {}),
// const SizedBox(width: 8),
// 5. 火技能按钮 (对应 SmallFunctionButton)
//_buildIconButton("assets/svgs/fire.svg", () {}),
_buildSliderBox(
AppLocalizations.of(
context,
).translate('remote_control.fire_skill'),
false,
context,
),
// const Spacer(), // 移除 Spacer,改用 MainAxisAlignment.spaceBetween
_buildExpandIconButton(
iconPath: "assets/svgs/remote_expand.svg",
selectedIconPath: "assets/svgs/remote_unexpand.svg",
isSelected: remoteState.topRightIsExpanded,
onTap: () {
_remoteControlCubit.toggleTopLeftExpand();
},
),
const Spacer(),
_buildExpandIconButton(
iconPath: "assets/svgs/remote_expand.svg",
selectedIconPath: "assets/svgs/remote_unexpand.svg",
isSelected: remoteState.topRightIsExpanded,
onTap: (){
_remoteControlCubit.toggleTopLeftExpand();
},
),
AnimatedSize(
duration: const Duration(milliseconds: 100),
curve: Curves.easeInOut,
child: Row(
children: !remoteState.topRightIsExpanded
? [
const SizedBox(width: 8),
_buildSwitchIconButton(
iconPath:
"assets/svgs/remote_recognition_off.svg",
selectedIconPath:
"assets/svgs/remote_recognition_on.svg",
isSelected: remoteState.obstacleRecognitionFlag,
onTap: () {
_remoteControlCubit
.toggleObstacleRecognition();
},
),
const SizedBox(width: 8),
_buildSwitchIconButton(
iconPath:
"assets/svgs/remote_video_left_off.svg",
selectedIconPath:
"assets/svgs/remote_video_left.svg",
isSelected: remoteState.showLeftPip,
onTap: () {
_remoteControlCubit.toggleLeftPip();
},
),
const SizedBox(width: 8),
_buildSwitchIconButton(
iconPath:
"assets/svgs/remote_video_right_off.svg",
selectedIconPath:
"assets/svgs/remote_video_right.svg",
isSelected: remoteState.showRightPip,
onTap: () {
_remoteControlCubit.toggleRightPip();
},
),
const SizedBox(width: 8),
_buildControlModeChip(
remoteState.runningStatusModel.controlMode,
context,
),
const SizedBox(width: 8),
_buildVoltageChip(
remoteState.runningStatusModel.voltage,
),
const SizedBox(width: 8),
_buildPingChip(remoteState.ping),
const SizedBox(width: 8),
]
: [], // 折叠时数组为空
),
),
// TODO
// _buildControlModeChip( _parseControlMode(remoteState.runningStatusModel.controlMode)),
AnimatedSize(
duration: const Duration(milliseconds: 100),
curve: Curves.easeInOut,
child: Row(
children: !remoteState.topRightIsExpanded
? [
const SizedBox(width: 8),
_buildSwitchIconButton(
iconPath: "assets/svgs/remote_recognition_off.svg",
selectedIconPath: "assets/svgs/remote_recognition_on.svg",
isSelected: remoteState.obstacleRecognitionFlag,
onTap: (){
_remoteControlCubit.toggleObstacleRecognition();
},
),
const SizedBox(width: 8),
_buildSwitchIconButton(
iconPath: "assets/svgs/remote_video_left_off.svg",
selectedIconPath: "assets/svgs/remote_video_left.svg",
isSelected: remoteState.showLeftPip,
onTap: (){
_remoteControlCubit.toggleLeftPip();
},
),
const SizedBox(width: 8),
_buildSwitchIconButton(
iconPath: "assets/svgs/remote_video_right_off.svg",
selectedIconPath: "assets/svgs/remote_video_right.svg",
isSelected: remoteState.showRightPip,
onTap: (){
_remoteControlCubit.toggleRightPip();
},
),
const SizedBox(width: 8),
_buildControlModeChip(remoteState.runningStatusModel.controlMode, context),
const SizedBox(width: 8),
_buildVoltageChip(remoteState.runningStatusModel.voltage),
const SizedBox(width: 8),
_buildPingChip(remoteState.ping),
const SizedBox(width: 8),
]
: [], // 折叠时数组为空
// const SizedBox(width: 8),
//_buildVoltageChip(remoteState.voltage),
// const SizedBox(width: 8),
// 5. 信号延迟 (对应 pingStatusChip)
//_buildPingChip(remoteState.ping),
// const SizedBox(width: 8),
// 6. 电量 (对应 StatusChipRight)
_buildBatteryChip(remoteState.battery),
const SizedBox(width: 8),
],
),
),
),
// TODO
// _buildControlModeChip( _parseControlMode(remoteState.runningStatusModel.controlMode)),
// const SizedBox(width: 8),
//_buildVoltageChip(remoteState.voltage),
// const SizedBox(width: 8),
// 5. 信号延迟 (对应 pingStatusChip)
//_buildPingChip(remoteState.ping),
// const SizedBox(width: 8),
// 6. 电量 (对应 StatusChipRight)
_buildBatteryChip(remoteState.battery),
const SizedBox(width: 8),
],
);
},
),
);
}
@@ -218,12 +285,12 @@ class TopStatusBar extends StatelessWidget {
),
boxShadow: isSelected
? [
BoxShadow(
color: const Color(0xFF0078D4).withOpacity(0.4),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
BoxShadow(
color: const Color(0xFF0078D4).withOpacity(0.4),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
padding: const EdgeInsets.all(7),
@@ -250,9 +317,11 @@ class TopStatusBar extends StatelessWidget {
Widget _buildControlModeChip(String mode, BuildContext context) {
String displayText;
if(mode == ''){
displayText = AppLocalizations.of(context).translate('remote_control.mode_none');
}else{
if (mode == '') {
displayText = AppLocalizations.of(
context,
).translate('remote_control.mode_none');
} else {
displayText = mode;
}
// switch (mode) {
@@ -302,10 +371,7 @@ class TopStatusBar extends StatelessWidget {
// 选中时为深蓝色,未选中时为半透明灰色
color: Colors.grey.withOpacity(0.4),
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 0.5,
),
border: Border.all(color: Colors.white.withOpacity(0.3), width: 0.5),
boxShadow: null,
),
padding: const EdgeInsets.all(7),
@@ -318,7 +384,6 @@ class TopStatusBar extends StatelessWidget {
);
}
Widget _buildVoltageChip(String voltage) {
return StatusChip(
text: "$voltage V",
@@ -363,9 +428,9 @@ class TopStatusBar extends StatelessWidget {
Widget _buildSliderBox(String label, bool isLeft, BuildContext context) {
// 1. 核心尺寸调整:足够宽的容器解决拥挤,高度匹配状态栏
const double boxWidth = 66; // 水平宽度放大,容纳左右图标
const double boxHeight = 32; // 高度和其他按钮保持一致
const double iconSize = 20; // 图标尺寸放大,避免过小拥挤
const double boxWidth = 66; // 水平宽度放大,容纳左右图标
const double boxHeight = 32; // 高度和其他按钮保持一致
const double iconSize = 20; // 图标尺寸放大,避免过小拥挤
return SizedBox(
width: boxWidth,
@@ -376,26 +441,37 @@ class TopStatusBar extends StatelessWidget {
quarterTurns: 1, // 旋转90度(竖向→横向),若方向反了可改为3
child: SizedBox(
// 旋转后宽高互换,这里给CCExpandSlider足够的显示空间
width: boxHeight, // 旋转后对应原高度
height: boxWidth, // 旋转后对应原宽度(足够长,不拥挤)
width: boxHeight, // 旋转后对应原高度
height: boxWidth, // 旋转后对应原宽度(足够长,不拥挤)
child: CCExpandSlider(
label: " ",
// 3. 图标适配旋转后的方向(仍用上下箭头,旋转后变为左右)因为外边旋转了 90度 参数传递也要转换
svgStart: "assets/svgs/remote_flame_plus.svg", // 旋转后→左箭头assets/svgs/remote_flame_minus.svg
svgStart:
"assets/svgs/remote_flame_plus.svg", // 旋转后→左箭头assets/svgs/remote_flame_minus.svg
svgCenter: "assets/svgs/remote_flame.svg",
svgEnd: "assets/svgs/remote_flame_minus.svg", // 旋转后→右箭头assets/svgs/remote_flame_plus.svg
svgEnd:
"assets/svgs/remote_flame_minus.svg", // 旋转后→右箭头assets/svgs/remote_flame_plus.svg
// 4. 放大图标尺寸,解决拥挤
iconSize: iconSize,
// 保留原有业务逻辑
onStart: () =>_handleSliderAction(label, "end", context),//_handleSliderAction(label, "start", context)
onStart: () => _handleSliderAction(
label,
"end",
context,
), //_handleSliderAction(label, "start", context)
onCenter: () => _handleSliderAction(label, "center", context),
onEnd: () => _handleSliderAction(label, "start", context),//_handleSliderAction(label, "end", context)
onEnd: () => _handleSliderAction(
label,
"start",
context,
), //_handleSliderAction(label, "end", context)
),
),
),
),
);
}
// 🔥 新增:统一的业务逻辑分发方法
void _handleSliderAction(String label, String action, BuildContext context) {
debugPrint('🎯 [Slider 业务] label: $label, action: $action');
@@ -412,17 +488,17 @@ class TopStatusBar extends StatelessWidget {
case "start":
debugPrint('2熄火');
// TODO: 发送 🔥点火指令
cubit.sendFireCommand(2);
cubit.sendFireCommand(2);
break;
case "center":
debugPrint('️0熄火');
// TODO: 发送点火停止指令
cubit.sendFireCommand(0);
cubit.sendFireCommand(0);
break;
case "end":
debugPrint('1点火)');
// TODO: 发送熄火后退指令
cubit.sendFireCommand(1);
cubit.sendFireCommand(1);
break;
}
}

View File

@@ -53,6 +53,13 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
}
Future<void> _initWebRTCConnection() async {
// 🔥 安全检查: URL 为空时不初始化
if (widget.streamUrl.isEmpty) {
debugPrint('⚠️ [WebRTC] streamUrl 为空,跳过初始化');
setState(() => _isInitialized = true); // 🔥 标记为已初始化,避免卡loading
return;
}
_peerConnection = await createPeerConnection({
"sdpSemantics": "unified-plan",
"iceServers": [
@@ -86,7 +93,15 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
await _peerConnection!.setRemoteDescription(RTCSessionDescription(data['sdp'], 'answer'));
final sdp = data['sdp'];
// 🔥 安全检查: SDP 不能为 null
if (sdp != null && sdp is String && sdp.isNotEmpty) {
await _peerConnection!.setRemoteDescription(RTCSessionDescription(sdp, 'answer'));
debugPrint('✅ [WebRTC] setRemoteDescription 成功');
} else {
debugPrint('❌ [WebRTC] SDP 为空或无效,跳过 setRemoteDescription');
}
}
} catch (e) {
debugPrint("信令错误: $e");

View File

@@ -1,4 +1,5 @@
import '../../domain/entities/drone_station_entity.dart';
import '../../domain/entities/video_stream_entity.dart';
abstract class DroneStationDataSource {
Future<List<DroneStationEntity>> getDroneStationList(int siteId);

View File

@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import '../datasources/drone_station_datasource.dart';
import '../../domain/entities/drone_station_entity.dart';
import '../../domain/entities/video_stream_entity.dart';
class DroneStationDataSourceImpl implements DroneStationDataSource {
final Dio dio;

View File

@@ -2,6 +2,7 @@ import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../datasources/drone_station_datasource.dart';
import '../../domain/entities/drone_station_entity.dart';
import '../../domain/entities/video_stream_entity.dart';
import '../../domain/repositories/drone_station_repository.dart';
class DroneStationRepositoryImpl implements DroneStationRepository {

View File

@@ -388,33 +388,3 @@ class DroneStationEntity extends Equatable {
userId,
];
}
/// 视频流实体
class VideoStreamEntity extends Equatable {
final String sn;
final String cameraIndex;
final String url;
final int expireTs;
final String urlType;
const VideoStreamEntity({
required this.sn,
required this.cameraIndex,
required this.url,
required this.expireTs,
required this.urlType,
});
factory VideoStreamEntity.fromJson(Map<String, dynamic> json) {
return VideoStreamEntity(
sn: json['sn'] ?? '',
cameraIndex: json['camera_index'] ?? '',
url: json['url'] ?? '',
expireTs: json['expire_ts'] ?? 0,
urlType: json['url_type'] ?? '',
);
}
@override
List<Object?> get props => [sn, cameraIndex, url, expireTs, urlType];
}

View File

@@ -1,13 +1,11 @@
import 'package:equatable/equatable.dart';
/// 视频流实体
class VideoStreamEntity extends Equatable {
final String sn;
final String cameraIndex;
final String
url; // 火山引擎 RTC 鉴权参数(包含 app_id、room_id、token、user_id、expire_time)
final String url;
final int expireTs;
final String urlType; // 标识是 "rtc" 还是其他类型
final String urlType;
const VideoStreamEntity({
required this.sn,
@@ -21,38 +19,43 @@ class VideoStreamEntity extends Equatable {
return VideoStreamEntity(
sn: json['sn'] ?? '',
cameraIndex: json['camera_index'] ?? '',
url: json['url'] ?? '', // 火山引擎 RTC 鉴权参数
url: json['url'] ?? '',
expireTs: json['expire_ts'] ?? 0,
urlType: json['url_type'] ?? 'rtc', // 默认为 rtc 类型
urlType: json['url_type'] ?? 'rtc',
);
}
/// 解析火山引擎 RTC 参数
/// url 格式示例: "app_id=xxx&room_id=xxx&token=xxx&user_id=xxx&expire_time=xxx"
Map<String, String> parseRtcParams() {
final params = <String, String>{};
final pairs = url.split('&');
for (final pair in pairs) {
final kv = pair.split('=');
if (kv.length == 2) {
// 对 URL 编码的字符进行解码(如 %2F -> /, %2B -> +)
params[kv[0]] = Uri.decodeComponent(kv[1]);
}
}
return params;
}
/// 获取火山引擎 App ID(从 URL 中解析)
String get appId => parseRtcParams()['app_id'] ?? '';
String get appId {
final params = parseRtcParams();
return params['app_id'] ?? params['appid'] ?? '';
}
/// 获取房间 ID(从 URL 中解析)
String get roomId => parseRtcParams()['room_id'] ?? '';
String get roomId {
final params = parseRtcParams();
return params['room_id'] ?? params['roomid'] ?? params['channel'] ?? '';
}
/// 获取 Token(从 URL 中解析)
String get token => parseRtcParams()['token'] ?? '';
String get token {
final params = parseRtcParams();
return params['token'] ?? '';
}
/// 获取用户 ID(从 URL 中解析)
String get userId => parseRtcParams()['user_id'] ?? '';
String get userId {
final params = parseRtcParams();
return params['user_id'] ?? params['uid'] ?? '';
}
@override
List<Object?> get props => [sn, cameraIndex, url, expireTs, urlType];

View File

@@ -1,6 +1,7 @@
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../entities/drone_station_entity.dart';
import '../entities/video_stream_entity.dart';
abstract class DroneStationRepository {
Future<Either<Failure, List<DroneStationEntity>>> getDroneStationList(

View File

@@ -1,6 +1,6 @@
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../entities/drone_station_entity.dart';
import '../entities/video_stream_entity.dart';
import '../repositories/drone_station_repository.dart';
class GetVideoStreamUseCase {

View File

@@ -76,7 +76,7 @@ class DroneStationBloc extends Bloc<DroneStationEvent, DroneStationState> {
result.fold(
(failure) => emit(VideoStreamError(failure.message)),
(videoStream) => emit(VideoStreamLoaded(videoStream)),
(videoStream) => emit(VideoStreamLoaded(videoStream, event.cameraPosition)),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart';
import '../../domain/entities/drone_station_entity.dart';
import '../../domain/entities/video_stream_entity.dart';
abstract class DroneStationState extends Equatable {
const DroneStationState();
@@ -62,11 +63,12 @@ class VideoStreamLoading extends DroneStationState {
class VideoStreamLoaded extends DroneStationState {
final VideoStreamEntity videoStream;
final String cameraPosition;
const VideoStreamLoaded(this.videoStream);
const VideoStreamLoaded(this.videoStream, this.cameraPosition);
@override
List<Object?> get props => [videoStream];
List<Object?> get props => [videoStream, cameraPosition];
}
class VideoStreamError extends DroneStationState {

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../core/di/injection.dart';
import '../../../../../core/app/app_user_cubit.dart';
import '../../../../../components/tcp_status_indicator.dart';
import '../../../../v2/site/presentation/cubit/site_cubit.dart';
import '../bloc/device_status_bloc.dart';
import '../bloc/device_status_event.dart';
@@ -14,6 +15,7 @@ import '../widgets/device_item_widget.dart';
import '../widgets/drone_station_item_card.dart';
import 'robot_list_page.dart';
import 'drone_station_detail_page.dart';
/// 设备状态页面 - 使用 BLoC 模式
class DeviceStatusPage extends StatelessWidget {
const DeviceStatusPage({super.key});
@@ -24,7 +26,8 @@ class DeviceStatusPage extends StatelessWidget {
final siteId = selectedSite?.id;
return BlocProvider(
create: (_) => sl<DeviceStatusBloc>()..add(DeviceStatusLoadData(siteId: siteId)),
create: (_) =>
sl<DeviceStatusBloc>()..add(DeviceStatusLoadData(siteId: siteId)),
child: const DeviceStatusView(),
);
}
@@ -51,9 +54,7 @@ class DeviceStatusView extends StatelessWidget {
_buildAppBar(),
_buildSearchBar(context),
_buildTypeFilterBar(context),
Expanded(
child: _buildContent(context, state),
),
Expanded(child: _buildContent(context, state)),
],
);
},
@@ -67,9 +68,9 @@ class DeviceStatusView extends StatelessWidget {
return Container(
height: 44.0,
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: const Row(
child: Row(
children: [
Text(
const Text(
'设备状态',
style: TextStyle(
fontSize: 20,
@@ -77,11 +78,23 @@ class DeviceStatusView extends StatelessWidget {
color: Color(0xFF1D2129),
),
),
Spacer(),
Icon(
Icons.more_vert,
size: 24,
color: Color(0xFF1D2129),
const Spacer(),
Row(
children: [
const Text(
'TCP',
style: TextStyle(fontSize: 12, color: Color(0xFF86909C)),
),
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.blue.withOpacity(0.1),
),
child: const TcpStatusIndicator(size: 12),
),
],
),
],
),
@@ -94,10 +107,7 @@ class DeviceStatusView extends StatelessWidget {
child: TextField(
decoration: InputDecoration(
hintText: '搜索设备名称/编号',
hintStyle: const TextStyle(
color: Color(0xFF86909C),
fontSize: 14,
),
hintStyle: const TextStyle(color: Color(0xFF86909C), fontSize: 14),
prefixIcon: const Icon(
Icons.search,
color: Color(0xFF86909C),
@@ -120,34 +130,22 @@ class DeviceStatusView extends StatelessWidget {
fillColor: const Color(0xFFF2F3F5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24.0),
borderSide: const BorderSide(
color: Color(0xFFE5E6EB),
width: 1,
),
borderSide: const BorderSide(color: Color(0xFFE5E6EB), width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24.0),
borderSide: const BorderSide(
color: Color(0xFFE5E6EB),
width: 1,
),
borderSide: const BorderSide(color: Color(0xFFE5E6EB), width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24.0),
borderSide: const BorderSide(
color: Color(0xFF165DFF),
width: 1,
),
borderSide: const BorderSide(color: Color(0xFF165DFF), width: 1),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1D2129),
),
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
onSubmitted: (value) {
// 🔥 点击键盘确定键时触发搜索
context.read<DeviceStatusBloc>().add(DeviceStatusSearch(value));
@@ -180,7 +178,9 @@ class DeviceStatusView extends StatelessWidget {
return GestureDetector(
onTap: () {
context.read<DeviceStatusBloc>().add(DeviceStatusChangeType(type));
context.read<DeviceStatusBloc>().add(
DeviceStatusChangeType(type),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -190,8 +190,12 @@ class DeviceStatusView extends StatelessWidget {
type,
style: TextStyle(
fontSize: 15,
color: isSelected ? const Color(0xFF135BFF) : const Color(0xFF666666),
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected
? const Color(0xFF135BFF)
: const Color(0xFF666666),
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
const SizedBox(height: 4),
@@ -199,12 +203,13 @@ class DeviceStatusView extends StatelessWidget {
width: 20,
height: 2,
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF0D57FF) : Colors.transparent,
color: isSelected
? const Color(0xFF0D57FF)
: Colors.transparent,
borderRadius: BorderRadius.circular(1),
),
),
],
),
);
},
@@ -232,9 +237,7 @@ class DeviceStatusView extends StatelessWidget {
Widget _buildDeviceList(BuildContext context, DeviceStatusState state) {
if (state is DeviceStatusLoading) {
return const Center(
child: CircularProgressIndicator(
color: Color(0xFF165DFF),
),
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
);
}
@@ -243,23 +246,18 @@ class DeviceStatusView extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Color(0xFF86909C),
),
const Icon(Icons.error_outline, size: 48, color: Color(0xFF86909C)),
const SizedBox(height: 16),
Text(
state.message,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF4E5969),
),
style: const TextStyle(fontSize: 14, color: Color(0xFF4E5969)),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.read<DeviceStatusBloc>().add(const DeviceStatusLoadData());
context.read<DeviceStatusBloc>().add(
const DeviceStatusLoadData(),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
@@ -284,8 +282,12 @@ class DeviceStatusView extends StatelessWidget {
// 🔥 再根据搜索关键词过滤
final filteredDevices = filteredByType.where((device) {
if (state.searchKeyword.isEmpty) return true;
return device.name.toLowerCase().contains(state.searchKeyword.toLowerCase()) ||
device.deviceId.toLowerCase().contains(state.searchKeyword.toLowerCase());
return device.name.toLowerCase().contains(
state.searchKeyword.toLowerCase(),
) ||
device.deviceId.toLowerCase().contains(
state.searchKeyword.toLowerCase(),
);
}).toList();
return RefreshIndicator(
@@ -334,18 +336,11 @@ class DeviceStatusView extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.location_off,
size: 48,
color: Color(0xFF86909C),
),
const Icon(Icons.location_off, size: 48, color: Color(0xFF86909C)),
const SizedBox(height: 16),
const Text(
'请先选择场站',
style: TextStyle(
fontSize: 14,
color: Color(0xFF4E5969),
),
style: TextStyle(fontSize: 14, color: Color(0xFF4E5969)),
),
],
),
@@ -353,14 +348,13 @@ class DeviceStatusView extends StatelessWidget {
}
return BlocProvider(
create: (_) => sl<DroneStationBloc>()..add(DroneStationLoadData(selectedSite.id)),
create: (_) =>
sl<DroneStationBloc>()..add(DroneStationLoadData(selectedSite.id)),
child: BlocBuilder<DroneStationBloc, DroneStationState>(
builder: (context, state) {
if (state is DroneStationLoading) {
return const Center(
child: CircularProgressIndicator(
color: Color(0xFF165DFF),
),
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
);
}
@@ -385,7 +379,9 @@ class DeviceStatusView extends StatelessWidget {
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.read<DroneStationBloc>().add(DroneStationLoadData(selectedSite.id));
context.read<DroneStationBloc>().add(
DroneStationLoadData(selectedSite.id),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
@@ -405,17 +401,16 @@ class DeviceStatusView extends StatelessWidget {
return const Center(
child: Text(
'该场站暂无无人机机场',
style: TextStyle(
fontSize: 14,
color: Color(0xFF4E5969),
),
style: TextStyle(fontSize: 14, color: Color(0xFF4E5969)),
),
);
}
return RefreshIndicator(
onRefresh: () async {
context.read<DroneStationBloc>().add(DroneStationRefresh(selectedSite.id));
context.read<DroneStationBloc>().add(
DroneStationRefresh(selectedSite.id),
);
},
color: const Color(0xFF165DFF),
child: ListView.builder(
@@ -429,7 +424,8 @@ class DeviceStatusView extends StatelessWidget {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DroneStationDetailPage(station: station),
builder: (context) =>
DroneStationDetailPage(station: station),
),
);
},
@@ -468,33 +464,21 @@ class DeviceStatusView extends StatelessWidget {
dotColor: const Color(0xFF86909C),
textColor: const Color(0xFF86909C), // 新增字体颜色
),
Container(
width: 1,
height: 40,
color: const Color(0xFFE5E6EB),
),
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
_buildStatItem(
value: state.deviceStatus.online.toString(),
label: '在线',
dotColor: const Color(0xFF00B42A),
textColor: const Color(0xFF00B42A),
),
Container(
width: 1,
height: 40,
color: const Color(0xFFE5E6EB),
),
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
_buildStatItem(
value: state.deviceStatus.exception.toString(),
label: '告警',
dotColor: const Color(0xFFFF7D00),
textColor: const Color(0xFFFF7D00),
),
Container(
width: 1,
height: 40,
color: const Color(0xFFE5E6EB),
),
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
_buildStatItem(
value: state.deviceStatus.offline.toString(),
label: '离线',
@@ -549,5 +533,4 @@ class DeviceStatusView extends StatelessWidget {
),
);
}
}

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:volc_engine_rtc/volc_engine_rtc.dart' as volc;
import 'package:agora_rtc_engine/agora_rtc_engine.dart' as agora;
import '../../../../../core/di/injection.dart';
import '../../domain/entities/drone_station_entity.dart';
import '../../domain/entities/video_stream_entity.dart';
import '../bloc/drone_station_bloc.dart';
import '../bloc/drone_station_event.dart';
import '../bloc/drone_station_state.dart';
@@ -9,6 +12,9 @@ import 'drone_video_control_page.dart';
import 'drone_mission_control_page.dart';
import 'drone_monitor_page.dart';
// SDK 类型枚举
enum RtcSdkType { volcengine, agora }
class DroneStationDetailPage extends StatefulWidget {
final DroneStationEntity station;
@@ -26,6 +32,21 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
bool isFloatingIndoor = true;
Offset floatingPosition = const Offset(20, 200);
// 视频流状态
VideoStreamEntity? _floatingVideoStream;
bool _isFloatingLoading = false;
String? _floatingErrorMessage;
String? _floatingRemoteUserId;
bool _isFloatingAgora = false;
// 火山引擎 RTC
volc.RTCEngine? _floatingRtcEngine;
volc.RTCRoom? _floatingRtcRoom;
volc.RTCViewContext? _floatingRemoteRenderContext;
// Agora RTC
agora.RtcEngine? _floatingAgoraEngine;
@override
void initState() {
super.initState();
@@ -41,9 +62,42 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
@override
void dispose() {
_bloc.close();
_destroyFloatingRtcEngine();
super.dispose();
}
// 销毁悬浮窗的 RTC 引擎
void _destroyFloatingRtcEngine() async {
// 销毁火山引擎 RTC
if (_floatingRtcRoom != null) {
try {
await _floatingRtcRoom?.leaveRoom();
} catch (_) {}
_floatingRtcRoom = null;
}
if (_floatingRtcEngine != null) {
try {
_floatingRtcEngine?.destroy();
} catch (_) {}
_floatingRtcEngine = null;
}
// 销毁 Agora RTC
if (_floatingAgoraEngine != null) {
try {
await _floatingAgoraEngine?.leaveChannel();
} catch (_) {}
try {
_floatingAgoraEngine?.release();
} catch (_) {}
_floatingAgoraEngine = null;
}
_floatingRemoteRenderContext = null;
_floatingRemoteUserId = null;
_isFloatingAgora = false;
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
@@ -67,7 +121,21 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
),
centerTitle: true,
),
body: BlocBuilder<DroneStationBloc, DroneStationState>(
body: BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
// 监听视频流加载状态
if (state is VideoStreamLoaded) {
setState(() {
_floatingVideoStream = state.videoStream;
});
_initFloatingRtcEngine();
} else if (state is VideoStreamError) {
setState(() {
_floatingErrorMessage = state.message;
_isFloatingLoading = false;
});
}
},
builder: (context, state) {
if (state is UAVDetailLoading) {
return const Center(
@@ -449,7 +517,14 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
icon: Icons.task,
label: '任务下发',
color: const Color(0xFF165DFF),
onTap: () {},
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DroneMissionControlPage(),
),
);
},
),
),
],
@@ -517,7 +592,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
GestureDetector(
onTap: () {
Navigator.pop(context);
setState(() => showFloatingMonitor = true);
_loadFloatingVideoStream();
},
child: Container(
padding: const EdgeInsets.all(16),
@@ -639,7 +714,10 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
const SizedBox(width: 8),
Expanded(
child: GestureDetector(
onTap: () => setState(() => isFloatingIndoor = false),
onTap: () {
setState(() => isFloatingIndoor = false);
_loadFloatingVideoStream();
},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 6,
@@ -665,7 +743,10 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
),
// 关闭按钮
GestureDetector(
onTap: () => setState(() => showFloatingMonitor = false),
onTap: () {
_destroyFloatingRtcEngine();
setState(() => showFloatingMonitor = false);
},
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(
@@ -775,6 +856,321 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
);
}
// 加载悬浮视频流
void _loadFloatingVideoStream() {
setState(() {
_isFloatingLoading = true;
_floatingErrorMessage = null;
showFloatingMonitor = true;
});
_destroyFloatingRtcEngine();
_bloc.add(
VideoStreamLoad(
sn: widget.station.gatewaySn,
cameraIndex: '165-0-7',
cameraPosition: isFloatingIndoor ? 'indoor' : 'outdoor',
),
);
}
// 初始化悬浮窗的 RTC 引擎
Future<void> _initFloatingRtcEngine() async {
if (_floatingVideoStream == null) return;
final appId = _floatingVideoStream!.appId;
final roomId = _floatingVideoStream!.roomId;
final token = _floatingVideoStream!.token;
final userId = _floatingVideoStream!.userId.isNotEmpty
? _floatingVideoStream!.userId
: 'user_${DateTime.now().millisecondsSinceEpoch}';
if (appId.isEmpty || roomId.isEmpty || token.isEmpty) {
setState(() {
_floatingErrorMessage = 'RTC 参数缺失';
_isFloatingLoading = false;
});
return;
}
debugPrint('=== 悬浮窗 RTC 初始化 ===');
debugPrint('AppId: $appId');
debugPrint('RoomId: $roomId');
debugPrint('UserId: $userId');
debugPrint('URL Type: ${_floatingVideoStream!.urlType}');
final sdkType = _floatingVideoStream!.urlType.toLowerCase() == 'agora'
? RtcSdkType.agora
: RtcSdkType.volcengine;
if (sdkType == RtcSdkType.agora) {
await _initFloatingAgoraEngine(appId, roomId, token, userId);
} else {
await _initFloatingVolcEngine(appId, roomId, token, userId);
}
}
// 初始化悬浮窗的 Agora 引擎
Future<void> _initFloatingAgoraEngine(
String appId,
String channelId,
String token,
String userId,
) async {
try {
debugPrint('=== Agora 悬浮窗初始化 ===');
_floatingAgoraEngine = agora.createAgoraRtcEngine();
await _floatingAgoraEngine!.initialize(
agora.RtcEngineContext(appId: appId),
);
debugPrint('Agora 引擎初始化成功');
// 启用视频模块
_floatingAgoraEngine!.enableVideo();
debugPrint('Agora 视频模块已启用');
_floatingAgoraEngine!.registerEventHandler(
agora.RtcEngineEventHandler(
onJoinChannelSuccess: (agora.RtcConnection connection, int elapsed) {
debugPrint('✅ 悬浮窗 Agora 加入频道成功: ${connection.channelId}');
},
onUserJoined: (agora.RtcConnection connection, int uid, int elapsed) {
debugPrint('✅ 悬浮窗 Agora 用户加入: uid=$uid');
setState(() {
_floatingRemoteUserId = uid.toString();
_isFloatingAgora = true;
_isFloatingLoading = false;
});
},
onUserOffline:
(
agora.RtcConnection connection,
int uid,
agora.UserOfflineReasonType reason,
) {
debugPrint('悬浮窗 Agora 用户离开: $uid');
if (uid.toString() == _floatingRemoteUserId) {
setState(() {
_floatingRemoteUserId = null;
});
}
},
onError: (agora.ErrorCodeType err, String msg) {
debugPrint('❌ 悬浮窗 Agora 错误: $err - $msg');
setState(() {
_floatingErrorMessage = 'Agora RTC 错误:$err';
_isFloatingLoading = false;
});
},
),
);
await _floatingAgoraEngine!.joinChannel(
token: token,
channelId: channelId,
uid: int.tryParse(userId) ?? 0,
options: agora.ChannelMediaOptions(
channelProfile:
agora.ChannelProfileType.channelProfileLiveBroadcasting,
clientRoleType: agora.ClientRoleType.clientRoleAudience,
autoSubscribeVideo: true,
autoSubscribeAudio: false,
),
);
} catch (e) {
debugPrint('❌ 悬浮窗 Agora RTC 初始化失败: $e');
setState(() {
_floatingErrorMessage = 'Agora RTC 初始化失败:$e';
_isFloatingLoading = false;
});
}
}
// 初始化悬浮窗的火山引擎
Future<void> _initFloatingVolcEngine(
String appId,
String roomId,
String token,
String userId,
) async {
try {
debugPrint('=== 悬浮窗 VolcEngine 初始化 ===');
final engineEventHandler = volc.IRTCEngineEventHandler(
onWarning: (volc.WarningCode code) {
debugPrint('Volc 悬浮窗 Warning: $code');
},
onError: (volc.ErrorCode code) {
debugPrint('Volc 悬浮窗 Error: $code');
setState(() {
_floatingErrorMessage = 'Volc RTC 错误:$code';
_isFloatingLoading = false;
});
},
);
_floatingRtcEngine = await volc.RTCEngine.createRTCEngine(
volc.RTCVideoContext(appId: appId, eventHandler: engineEventHandler),
);
if (_floatingRtcEngine == null) {
debugPrint('Volc 悬浮窗引擎创建失败');
setState(() {
_floatingErrorMessage = 'Volc RTC 引擎创建失败';
_isFloatingLoading = false;
});
return;
}
_floatingRtcRoom = await _floatingRtcEngine!.createRTCRoom(roomId);
if (_floatingRtcRoom == null) {
debugPrint('Volc 悬浮窗房间创建失败');
setState(() {
_floatingErrorMessage = 'Volc RTC 房间创建失败';
_isFloatingLoading = false;
});
return;
}
final roomEventHandler = volc.IRTCRoomEventHandler(
onUserPublishStreamVideo:
(String userId, volc.StreamInfo streamInfo, bool isPublish) {
debugPrint('Volc 悬浮窗 远端用户 $userId 视频流状态: $isPublish');
setState(() {
if (isPublish) {
_floatingRemoteUserId = userId;
_isFloatingAgora = false;
_floatingRemoteRenderContext =
volc.RTCViewContext.remoteContext(
roomId: roomId,
userId: userId,
);
_isFloatingLoading = false;
} else {
if (userId == _floatingRemoteUserId) {
_floatingRemoteRenderContext = null;
_floatingRemoteUserId = null;
}
}
});
},
onUserLeave: (String userId, int reason) {
debugPrint('Volc 悬浮窗 用户离开: $userId');
if (userId == _floatingRemoteUserId) {
setState(() {
_floatingRemoteRenderContext = null;
_floatingRemoteUserId = null;
});
}
},
);
await _floatingRtcRoom?.setRTCRoomEventHandler(roomEventHandler);
await _floatingRtcRoom?.joinRoom(
token: token,
userInfo: volc.UserInfo(userId: userId, extraInfo: ''),
userVisibility: true,
roomConfig: volc.RoomConfig(
isPublishAudio: false,
isPublishVideo: false,
isAutoSubscribeAudio: false,
isAutoSubscribeVideo: true,
),
);
} catch (e) {
debugPrint('❌ 悬浮窗 Volc RTC 初始化失败: $e');
setState(() {
_floatingErrorMessage = 'Volc RTC 初始化失败:$e';
_isFloatingLoading = false;
});
}
}
// 构建悬浮窗视频内容
Widget _buildFloatingVideoContent() {
if (_isFloatingLoading) {
return Container(
height: 140,
color: const Color(0xFF0D1117),
child: const Center(
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
),
);
}
if (_floatingErrorMessage != null) {
return Container(
height: 140,
color: const Color(0xFF0D1117),
child: Center(
child: Text(
_floatingErrorMessage!,
style: const TextStyle(color: Colors.red, fontSize: 12),
textAlign: TextAlign.center,
),
),
);
}
if (_floatingRemoteUserId == null) {
return Container(
height: 140,
color: const Color(0xFF0D1117),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.video_camera_front, size: 32, color: Colors.grey),
SizedBox(height: 8),
Text(
'等待视频流...',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
);
}
if (_isFloatingAgora && _floatingAgoraEngine != null) {
return SizedBox(
height: 140,
child: agora.AgoraVideoView(
controller: agora.VideoViewController.remote(
rtcEngine: _floatingAgoraEngine!,
canvas: agora.VideoCanvas(uid: int.parse(_floatingRemoteUserId!)),
connection: agora.RtcConnection(
channelId: _floatingVideoStream?.roomId ?? '',
),
),
),
);
}
if (!_isFloatingAgora && _floatingRemoteRenderContext != null) {
return SizedBox(
height: 140,
child: volc.RTCSurfaceView(
context: _floatingRemoteRenderContext!,
renderMode: volc.VideoRenderMode.hidden,
),
);
}
return Container(
height: 140,
color: const Color(0xFF0D1117),
child: const Center(
child: Text(
'视频初始化中...',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
);
}
Widget _buildCameraListRow(String label, List<CameraInfo> cameras) {
String value = cameras
.map((c) => '${c.cameraIndex}:${c.cameraPosition}')

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../../core/router/route_paths.dart';
import '../widgets/robot_header_card.dart';
import '../widgets/robot_status_bar.dart';
import '../widgets/robot_control_panel.dart';
@@ -81,8 +83,7 @@ class RobotControlPage extends StatelessWidget {
Widget _buildPathPlanningCard(BuildContext context) {
return GestureDetector(
onTap: () {
// TODO: 跳转到路径规划页面
debugPrint('点击路径规划');
context.push(RoutePaths.routePlan);
},
child: Container(
padding: const EdgeInsets.all(16),

View File

@@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../../../../core/di/injection.dart';
import '../../../../../core/logging/i_logger_service.dart';
import '../../../../devices/presentation/bloc/devices_cubit.dart';
import '../../../../devices/domain/entities/device_entity.dart';
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
import '../../../../v2/site/presentation/cubit/site_cubit.dart';
import '../../data/models/robot_data_model.dart';
import '../bloc/robot_list_bloc.dart';
@@ -478,9 +481,12 @@ class _RobotListViewState extends State<RobotListView> {
battery: robot.battery,
task: robot.task,
onTap: () {
// 1. 将当前机器人设置为全局选中设备
// final logger = sl<ILoggerService>();
debugPrint('📱 [选中机器人] name: ${robot.name}, id: ${robot.id}, type: ${robot.type}, status: ${robot.status}, battery: ${robot.battery}, task: ${robot.task}');
// 1. 将当前机器人设置为全局待控制设备(用 robot.name 作为 deviceName)
final device = DeviceEntity(
deviceName: robot.id,
deviceName: robot.name, // ✅ 修正: 用 name 而不是 id
productId: -1,
productName: robot.type,
tenantId: 0,
@@ -489,7 +495,9 @@ class _RobotListViewState extends State<RobotListView> {
onlineStatus: robot.status == '在线' ? 1 : 0,
);
context.read<DevicesCubit>().selectDevice(device);
// 🔥 使用 GetIt 直接获取 RemoteControlCubit 单例
final remoteCubit = GetIt.I<RemoteControlCubit>();
remoteCubit.setTargetDevice(device);
// 2. 跳转到机器人控制页面
final robotMap = {

View File

@@ -33,142 +33,135 @@ class RobotControlPanel extends StatelessWidget {
debugPrint('✅ 设置选中设备: ${device.deviceName}');
context.read<DevicesCubit>().selectDevice(device);
// 打印当前选中的设备信息
final selectedDevice = context.read<DevicesCubit>().state.selectedDevice;
debugPrint('📱 当前选中设备信息:');
debugPrint(' - deviceName: ${selectedDevice?.deviceName}');
debugPrint(' - productName: ${selectedDevice?.productName}');
debugPrint(' - status: ${selectedDevice?.status}');
debugPrint(' - onlineStatus: ${selectedDevice?.onlineStatus}');
// 🔥 同时设置为全局待控制设备
final remoteCubit = sl<RemoteControlCubit>();
remoteCubit.setTargetDevice(device);
debugPrint('🎯 已设置 targetDevice: ${device.deviceName}');
// 🔥 在跳转前获取 DevicesCubit,避免在 builder 内部 context 找不到
final devicesCubit = context.read<DevicesCubit>();
// 2. 跳转到远程遥控页面
debugPrint('🚀 准备跳转...');
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (_) => sl<RemoteControlCubit>()..startControlLoop(),
builder: (newContext) => MultiBlocProvider(
providers: [
BlocProvider.value(
value: remoteCubit,
), // 🔥 复用已设置 targetDevice 的单例
BlocProvider.value(value: devicesCubit), // 🔥 同时提供 DevicesCubit
],
child: const RemoteControlPage(),
),
),
);
},
child: Container(
padding: const EdgeInsets.all(12),
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: [
const Text(
'控制面板',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x0D000000),
blurRadius: 8,
offset: Offset(0, 2),
),
),
const SizedBox(height: 12),
// 方向控制盘
Center(
child: SizedBox(
width: 120,
height: 120,
child: Stack(
alignment: Alignment.center,
children: [
// 背景圆
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: const Color(0xFFF5F6F8),
shape: BoxShape.circle,
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'控制面板',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
),
const SizedBox(height: 12),
// 方向控制盘
Center(
child: SizedBox(
width: 120,
height: 120,
child: Stack(
alignment: Alignment.center,
children: [
// 背景圆
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: const Color(0xFFF5F6F8),
shape: BoxShape.circle,
),
),
),
// 上
Positioned(
top: 8,
child: _buildDirectionButton(Icons.arrow_upward, () {}),
),
// 下
Positioned(
bottom: 8,
child: _buildDirectionButton(Icons.arrow_downward, () {}),
),
// 左
Positioned(
left: 8,
child: _buildDirectionButton(Icons.arrow_back, () {}),
),
// 右
Positioned(
right: 8,
child: _buildDirectionButton(Icons.arrow_forward, () {}),
),
// 中心按钮
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF4080FF),
Color(0xFF165DFF),
// 上
Positioned(
top: 8,
child: _buildDirectionButton(Icons.arrow_upward, () {}),
),
// 下
Positioned(
bottom: 8,
child: _buildDirectionButton(Icons.arrow_downward, () {}),
),
// 左
Positioned(
left: 8,
child: _buildDirectionButton(Icons.arrow_back, () {}),
),
// 右
Positioned(
right: 8,
child: _buildDirectionButton(Icons.arrow_forward, () {}),
),
// 中心按钮
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF4080FF), Color(0xFF165DFF)],
),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: const Color(0xFF165DFF).withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: const Color(0xFF165DFF).withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
),
],
],
),
),
),
),
const SizedBox(height: 12),
// 速度档位
const Text(
'速度档位',
style: TextStyle(
fontSize: 13,
color: Color(0xFF86909C),
const SizedBox(height: 12),
// 速度档位
const Text(
'速度档位',
style: TextStyle(fontSize: 13, color: Color(0xFF86909C)),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildSpeedButton('低速', false, () {}),
),
const SizedBox(width: 12),
Expanded(
child: _buildSpeedButton('中速', true, () {}),
),
const SizedBox(width: 12),
Expanded(
child: _buildSpeedButton('高速', false, () {}),
),
],
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _buildSpeedButton('低速', false, () {})),
const SizedBox(width: 12),
Expanded(child: _buildSpeedButton('中速', true, () {})),
const SizedBox(width: 12),
Expanded(child: _buildSpeedButton('高速', false, () {})),
],
),
],
),
),
);
}

View File

@@ -1,15 +1,38 @@
import 'package:dio/dio.dart';
import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
import 'package:maibu_satabot_v2/features/v2/home/data/datasources/site_datasource.dart';
import 'package:maibu_satabot_v2/features/v2/home/domain/entities/site_entity.dart';
class SiteDataSourceImpl implements SiteDataSource {
final Dio dio;
final UserStorage _userStorage;
final AppUserCubit _appUserCubit;
SiteDataSourceImpl(this.dio);
SiteDataSourceImpl(this.dio, this._userStorage, this._appUserCubit);
@override
Future<List<SiteEntity>> getSiteList(int orgId) async {
print('🔍 [SiteDataSource] 开始获取 Token...');
print('🔍 [SiteDataSource] AppUserCubit 当前用户: ${_appUserCubit.state.user?.username}');
// 优先从全局状态获取 Token(更快更可靠)
var token = _appUserCubit.state.user?.token;
print('🔍 [SiteDataSource] 从 AppUserCubit 获取的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}');
// 如果全局状态没有,再从本地存储获取
if (token == null) {
print('⚠️ [SiteDataSource] AppUserCubit 没有 Token,尝试从本地存储获取...');
final user = await _userStorage.getUser();
token = user?.token;
print('🔍 [SiteDataSource] 从本地存储获取的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}');
}
print('🔑 [SiteDataSource] 获取到的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}');
// 构建查询参数:orgId 为 0 时不传递
final queryParams = <String, dynamic>{
'pageNum': 1,
@@ -23,6 +46,11 @@ class SiteDataSourceImpl implements SiteDataSource {
final response = await dio.get(
HttpApiConsts.getSiteList,
queryParameters: queryParams,
options: Options(
headers: {
'Authorization': token != null ? 'Bearer $token' : '',
},
),
);
if (response.statusCode != 200) {

View File

@@ -11,6 +11,7 @@ import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/work_orde
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/quick_entry_card.dart';
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/power_trend_chart.dart';
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/plant_overview_card.dart';
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/tcp_status_indicator.dart';
class HomeV2Page extends StatefulWidget {
const HomeV2Page({super.key});
@@ -100,10 +101,7 @@ class _HomeV2PageState extends State<HomeV2Page> {
),
),
),
IconButton(
icon: const Icon(Icons.qr_code_scanner, size: 24, color: Color(0xFF1D2129)),
onPressed: () => print('点击扫码'),
),
const TcpStatusIndicator(),
],
),
),
@@ -206,7 +204,12 @@ class _HomeV2PageState extends State<HomeV2Page> {
super.didChangeDependencies();
// 首次进入时自动加载数据,但不显示转圈
if (_bloc.state is HomeV2Initial) {
_bloc.add(const HomeV2LoadData());
// 延迟 100ms,确保 AppUserCubit 状态已更新
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
_bloc.add(const HomeV2LoadData());
}
});
}
}

View File

@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/core/network/tcp/tcp_status_cubit.dart';
/// TCP 连接状态指示灯组件
class TcpStatusIndicator extends StatefulWidget {
const TcpStatusIndicator({super.key});
@override
State<TcpStatusIndicator> createState() => _TcpStatusIndicatorState();
}
class _TcpStatusIndicatorState extends State<TcpStatusIndicator>
with SingleTickerProviderStateMixin {
late TcpStatusCubit _tcpStatusCubit;
bool _hasActivity = false;
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_tcpStatusCubit = GetIt.I<TcpStatusCubit>();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat(reverse: true);
_animation = Tween<double>(begin: 0.6, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_tcpStatusCubit.stream.listen((state) {
if (state.status != TcpConnectionStatus.disconnected) {
_hasActivity = true;
}
if (mounted) {
setState(() {});
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = _tcpStatusCubit.state;
Color color;
String tooltip;
bool shouldAnimate;
switch (state.status) {
case TcpConnectionStatus.connected:
color = Colors.green;
tooltip = 'TCP已连接';
shouldAnimate = false;
break;
case TcpConnectionStatus.connecting:
color = Colors.yellow;
tooltip = 'TCP连接中...';
shouldAnimate = true;
break;
case TcpConnectionStatus.error:
color = Colors.red;
tooltip = state.errorMessage ?? 'TCP连接错误';
shouldAnimate = false;
break;
case TcpConnectionStatus.disconnected:
default:
color = Colors.grey;
tooltip = 'TCP未连接';
shouldAnimate = false;
break;
}
return Tooltip(
message: tooltip,
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
final opacity = shouldAnimate ? _animation.value : 1.0;
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color.withOpacity(opacity),
shape: BoxShape.circle,
boxShadow: state.status == TcpConnectionStatus.connected
? [
BoxShadow(
color: Colors.green.withOpacity(0.5 * opacity),
blurRadius: 6 * opacity,
spreadRadius: 2 * opacity,
),
]
: [],
),
);
},
),
);
}
}

View File

@@ -487,35 +487,35 @@ class _UpdateCheckerState extends State<_UpdateChecker> {
),
),
),
// 调试面板
Positioned(
top: 40,
right: 10,
child: Material(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'状态: ${state.runtimeType.toString().replaceAll('Update', '')}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
// 🔥 暂时关闭刷新按钮和点击阴影
// IconButton(
// icon: const Icon(Icons.refresh, color: Colors.white),
// onPressed: () => context.read<UpdateCubit>().checkUpdate(),
// ),
],
),
),
),
),
// 调试面板 - 暂时关闭
// Positioned(
// top: 40,
// right: 10,
// child: Material(
// color: Colors.black54,
// borderRadius: BorderRadius.circular(8),
// child: Padding(
// padding: const EdgeInsets.all(8.0),
// child: Column(
// mainAxisSize: MainAxisSize.min,
// children: [
// Text(
// '状态: ${state.runtimeType.toString().replaceAll('Update', '')}',
// style: const TextStyle(
// color: Colors.white,
// fontSize: 12,
// ),
// ),
// // 🔥 暂时关闭刷新按钮和点击阴影
// // IconButton(
// // icon: const Icon(Icons.refresh, color: Colors.white),
// // onPressed: () => context.read<UpdateCubit>().checkUpdate(),
// // ),
// ],
// ),
// ),
// ),
// ),
],
);
},

View File

@@ -9,6 +9,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "93.0.0"
agora_rtc_engine:
dependency: "direct main"
description:
name: agora_rtc_engine
sha256: "6559294d18ce4445420e19dbdba10fb58cac955cd8f22dbceae26716e194d70e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.5.3"
analyzer:
dependency: transitive
description:
@@ -628,6 +636,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
hybrid_runtime:
dependency: transitive
description:
name: hybrid_runtime
sha256: "8c25ccf0f84edc0c0a9bece9266e57ce8839ebaa9d7c2f6ee6d5321857c6f67f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.1.0"
image:
dependency: "direct main"
description:
@@ -716,6 +732,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.5"
iris_method_channel:
dependency: transitive
description:
name: iris_method_channel
sha256: "114bbe541369add8dd0727858e7df5764f375e3fb88374ad487301733fddb57f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.5"
isar_community:
dependency: "direct main"
description:
@@ -1593,6 +1617,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "15.0.2"
volc_engine_rtc:
dependency: "direct main"
description:
name: volc_engine_rtc
sha256: b14448d7dd19a53abf2da9b91606df4bef91bac8491cff61b43aaa0de67f0006
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.60.4"
watcher:
dependency: transitive
description:

View File

@@ -94,7 +94,10 @@ dependencies:
video_player: ^2.8.2
# ===== 火山引擎 RTC 实时音视频 =====
#volc_engine_rtc: ^3.60.4
volc_engine_rtc: ^3.60.4
# ===== Agora 声网 RTC =====
agora_rtc_engine: ^6.5.0
# ===== 屏幕适配,高刷等 =====